Skip to main content

siplot/core/
fitting.rs

1//! Basic curve fitting utilities.
2//!
3//! Provides traits and simple implementations for curve fitting (Linear, Gaussian estimation).
4//!
5//! Additionally provides an iterative Levenberg-Marquardt least-squares solver
6//! ([`leastsq`]) ported from silx `silx/math/fit/leastsq.py`, together with the
7//! peak models (Gaussian/Lorentzian/PseudoVoigt) from
8//! `silx/math/fit/functions/src/funs.c` and their initial-parameter estimators
9//! mirroring `silx/math/fit/fittheories.py`.
10
11/// Result of a curve fit.
12#[derive(Debug, Clone)]
13pub struct FitResult {
14    /// The fitted y values for the input x values.
15    pub y_fit: Vec<f64>,
16    /// The parameters of the fit function.
17    pub parameters: Vec<f64>,
18    /// Names of the parameters.
19    pub param_names: Vec<String>,
20}
21
22/// A function that can be fitted to data.
23pub trait FitFunction {
24    /// Name of the function.
25    fn name(&self) -> &str;
26
27    /// Fit the function to the given data.
28    fn fit(&self, x: &[f64], y: &[f64]) -> Option<FitResult>;
29}
30
31/// Simple linear fit: y = m*x + c
32pub struct LinearFit;
33
34impl FitFunction for LinearFit {
35    fn name(&self) -> &str {
36        "Linear"
37    }
38
39    fn fit(&self, x: &[f64], y: &[f64]) -> Option<FitResult> {
40        if x.len() != y.len() || x.len() < 2 {
41            return None;
42        }
43        let n = x.len() as f64;
44        let sum_x: f64 = x.iter().sum();
45        let sum_y: f64 = y.iter().sum();
46        let sum_xy: f64 = x.iter().zip(y.iter()).map(|(&xi, &yi)| xi * yi).sum();
47        let sum_xx: f64 = x.iter().map(|&xi| xi * xi).sum();
48
49        let denominator = n * sum_xx - sum_x * sum_x;
50        if denominator.abs() < 1e-12 {
51            return None;
52        }
53
54        let m = (n * sum_xy - sum_x * sum_y) / denominator;
55        let c = (sum_y - m * sum_x) / n;
56
57        let y_fit = x.iter().map(|&xi| m * xi + c).collect();
58
59        Some(FitResult {
60            y_fit,
61            parameters: vec![m, c],
62            param_names: vec!["Slope (m)".to_string(), "Intercept (c)".to_string()],
63        })
64    }
65}
66
67/// Gaussian estimation: y = A * exp(-(x - mu)^2 / (2 * sigma^2)) + bg
68/// Note: This is a direct analytical estimation based on moments/peak, not an iterative L-M fit.
69pub struct GaussianEstimateFit;
70
71impl FitFunction for GaussianEstimateFit {
72    fn name(&self) -> &str {
73        "Gaussian (Estimate)"
74    }
75
76    fn fit(&self, x: &[f64], y: &[f64]) -> Option<FitResult> {
77        if x.len() != y.len() || x.len() < 3 {
78            return None;
79        }
80
81        let bg = y.iter().copied().fold(f64::INFINITY, f64::min);
82        let mut max_y = f64::NEG_INFINITY;
83        let mut max_idx = 0;
84        for (i, &yi) in y.iter().enumerate() {
85            if yi > max_y {
86                max_y = yi;
87                max_idx = i;
88            }
89        }
90
91        let a = max_y - bg;
92        let mu = x[max_idx];
93
94        // Estimate FWHM by finding first points below half max
95        let half_max = bg + a / 2.0;
96        let mut left_idx = max_idx;
97        while left_idx > 0 && y[left_idx] > half_max {
98            left_idx -= 1;
99        }
100        let mut right_idx = max_idx;
101        while right_idx < y.len() - 1 && y[right_idx] > half_max {
102            right_idx += 1;
103        }
104
105        let fwhm = x[right_idx] - x[left_idx];
106        let sigma = if fwhm > 0.0 {
107            fwhm / 2.355
108        } else {
109            (x.last().unwrap() - x.first().unwrap()) / 4.0
110        };
111
112        let y_fit = x
113            .iter()
114            .map(|&xi| {
115                let z = (xi - mu) / sigma;
116                a * (-0.5 * z * z).exp() + bg
117            })
118            .collect();
119
120        Some(FitResult {
121            y_fit,
122            parameters: vec![a, mu, sigma, bg],
123            param_names: vec![
124                "Amplitude (A)".to_string(),
125                "Center (mu)".to_string(),
126                "Sigma".to_string(),
127                "Background".to_string(),
128            ],
129        })
130    }
131}
132
133// ---------------------------------------------------------------------------
134// Iterative Levenberg-Marquardt least-squares core.
135//
136// Ported from silx `silx/math/fit/leastsq.py` (leastsq / chisq_alpha_beta),
137// itself a refactor of PyMca Gefit. We port the *unconstrained* path: silx's
138// CFREE branch where `n_free == nparameters`, `noigno == range(n)`, and
139// `derivfactor == 1`. Constraints (positivity/quoted/factor/...) are DEFERRED.
140// ---------------------------------------------------------------------------
141
142/// `LOG2`, matching the C constant in `funs.c`
143/// (`#define LOG2 0.69314718055994529`, i.e. `ln(2)`). Used to convert FWHM to
144/// sigma: `sigma = fwhm / (2 * sqrt(2 * LOG2))`.
145pub const LOG2: f64 = std::f64::consts::LN_2;
146
147/// `2 * sqrt(2 * LOG2)`: the FWHM/sigma conversion factor for a Gaussian.
148/// silx computes `inv_two_sqrt_two_log2 = 1 / (2*sqrt(2*LOG2))` and uses
149/// `sigma = fwhm * inv_two_sqrt_two_log2`.
150pub fn fwhm_to_sigma_factor() -> f64 {
151    2.0 * (2.0 * LOG2).sqrt()
152}
153
154/// Outputs of a successful [`leastsq`] run.
155///
156/// Mirrors the silx `leastsq` return tuple (`fittedpar`, `cov`, `ddict`) with
157/// the unconstrained-only subset of `ddict` we need: `chisq`, `reduced_chisq`,
158/// `niter`, `nfev`.
159#[derive(Debug, Clone)]
160pub struct LeastSqResult {
161    /// Optimal parameter values minimising the weighted sum of squared
162    /// residuals (silx `fittedpar`).
163    pub parameters: Vec<f64>,
164    /// Estimated covariance matrix of the parameters, row-major
165    /// `n_param x n_param` (silx `cov0 = inv(alpha0)`). Standard errors are the
166    /// square roots of the diagonal: `perr[i] = sqrt(cov[i][i])`.
167    pub covariance: Vec<Vec<f64>>,
168    /// Per-parameter uncertainties propagated through the applied constraints
169    /// (silx `ddict["uncertainties"]` via `_get_sigma_parameters`). For an
170    /// unconstrained fit this equals [`LeastSqResult::std_errors`]; with
171    /// constraints it additionally carries the QUOTED `B·cos` factor and ties
172    /// FACTOR/DELTA/SUM uncertainties to their reference parameter.
173    pub uncertainties: Vec<f64>,
174    /// The chi-square `sum( weight * (model - y)^2 )` at the optimum
175    /// (silx `chisq0`).
176    pub chisq: f64,
177    /// Reduced chi-square `chisq / (M - n_free)` where `M` is the number of
178    /// data points and `n_free` the number of fitted parameters (silx
179    /// `reduced_chisq`). `None` when degrees of freedom are non-positive.
180    pub reduced_chisq: Option<f64>,
181    /// Number of iterations performed (silx `niter`).
182    pub niter: usize,
183    /// Number of model function evaluations (silx `nfev`).
184    pub nfev: usize,
185}
186
187impl LeastSqResult {
188    /// Per-parameter standard error: `sqrt(abs(cov[i][i]))`.
189    ///
190    /// Mirrors the silx docstring note "To compute one standard deviation
191    /// errors use `perr = np.sqrt(np.diag(pcov))`"; `abs` guards a tiny
192    /// negative diagonal from round-off, matching silx `sqrt(abs(diag(cov0)))`.
193    pub fn std_errors(&self) -> Vec<f64> {
194        (0..self.parameters.len())
195            .map(|i| self.covariance[i][i].abs().sqrt())
196            .collect()
197    }
198}
199
200/// Why a [`leastsq`] call could not run / converge to a covariance.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum FitError {
203    /// `xdata` and `ydata` have different lengths.
204    LengthMismatch,
205    /// There are no free parameters (silx `raise ValueError("No free
206    /// parameters to fit")`).
207    NoFreeParameters,
208    /// Fewer data points than free parameters: the problem is under-determined.
209    NotEnoughData,
210    /// A non-finite value (NaN/inf) was found in inputs while `check_finite`
211    /// is on (silx `asarray_chkfinite`).
212    NonFinite,
213    /// The curvature matrix `alpha0` is singular and cannot be inverted, so no
214    /// covariance is available (silx `LinAlgError` from `inv(alpha0)`).
215    SingularMatrix,
216    /// A QUOTED constraint has equal min/max (`B == 0`), so the `sin/arcsin`
217    /// reparametrisation is undefined (silx `raise ValueError("Invalid
218    /// parameter limits")`).
219    InvalidConstraint,
220    /// A FACTOR/DELTA/SUM constraint references a parameter index outside the
221    /// parameter vector.
222    BadConstraintReference,
223}
224
225/// A per-parameter fit constraint for [`leastsq_constrained`].
226///
227/// Faithful to silx `silx.math.fit.leastsq` constraint codes (`CFREE`=0 …
228/// `CIGNORED`=7). The constraint set has one entry per parameter; a parameter
229/// is either *fitted* (Free/Positive/Quoted), *held* (Fixed/Ignored), or
230/// *derived* from another parameter (Factor/Delta/Sum).
231#[derive(Debug, Clone, Copy, PartialEq)]
232pub enum Constraint {
233    /// `CFREE` — varied with no restriction.
234    Free,
235    /// `CPOSITIVE` — varied but kept positive (silx takes `abs(value)` rather
236    /// than the commented-out square reparametrisation).
237    Positive,
238    /// `CQUOTED` — confined to `[min, max]` via silx's `A + B·sin(arcsin(...) +
239    /// Δ)` reparametrisation (`A = (max+min)/2`, `B = (max−min)/2`). A start
240    /// value outside `[min, max]` is held fixed at its start (silx warning
241    /// path).
242    Quoted {
243        /// Lower bound (silx `min(constraints[i][1], constraints[i][2])`).
244        min: f64,
245        /// Upper bound (silx `max(...)`).
246        max: f64,
247    },
248    /// `CFIXED` — held at its starting value; not fitted, gets 100 %
249    /// uncertainty in the covariance.
250    Fixed,
251    /// `CFACTOR` — `value = factor · params[reference]` (silx `CFACTOR`).
252    Factor {
253        /// Index of the parameter this one is tied to.
254        reference: usize,
255        /// Multiplicative factor.
256        factor: f64,
257    },
258    /// `CDELTA` — `value = delta + params[reference]` (silx `CDELTA`).
259    Delta {
260        /// Index of the parameter this one is tied to.
261        reference: usize,
262        /// Additive offset.
263        delta: f64,
264    },
265    /// `CSUM` — `value = sum − params[reference]` (silx `CSUM`).
266    Sum {
267        /// Index of the parameter this one is tied to.
268        reference: usize,
269        /// Constant the pair sums to.
270        sum: f64,
271    },
272    /// `CIGNORED` — set to 0 and stripped from the model call (silx `CIGNORED`;
273    /// the model must accept the reduced parameter count).
274    Ignored,
275}
276
277/// Apply the dependent-parameter relations to a full parameter vector (silx
278/// `_get_parameters`). Free parameters pass through, Positive is `abs`, Quoted
279/// passes through (its bounding is done at the step site), Factor/Delta/Sum are
280/// recomputed from their reference, Ignored becomes 0. Two passes so that the
281/// independent values are set before the dependent ones read them.
282fn get_parameters(params: &[f64], constraints: &[Constraint]) -> Vec<f64> {
283    let mut out: Vec<f64> = params
284        .iter()
285        .zip(constraints)
286        .map(|(&p, c)| match c {
287            Constraint::Positive => p.abs(),
288            _ => p,
289        })
290        .collect();
291    for (i, c) in constraints.iter().enumerate() {
292        match *c {
293            Constraint::Factor { reference, factor } => out[i] = factor * out[reference],
294            Constraint::Delta { reference, delta } => out[i] = delta + out[reference],
295            Constraint::Sum { reference, sum } => out[i] = sum - out[reference],
296            Constraint::Ignored => out[i] = 0.0,
297            _ => {}
298        }
299    }
300    out
301}
302
303/// Propagate free-parameter sigmas back onto the full parameter vector through
304/// the constraints (silx `_get_sigma_parameters`). `sigma0` holds one entry per
305/// free parameter, in free-parameter order.
306fn get_sigma_parameters(
307    parameters: &[f64],
308    sigma0: &[f64],
309    constraints: &[Constraint],
310) -> Vec<f64> {
311    let mut sigma_par = vec![0.0_f64; parameters.len()];
312    let mut n_free = 0usize;
313    for (i, c) in constraints.iter().enumerate() {
314        match *c {
315            Constraint::Free | Constraint::Positive => {
316                sigma_par[i] = sigma0[n_free];
317                n_free += 1;
318            }
319            Constraint::Quoted { min, max } => {
320                let pmax = min.max(max);
321                let pmin = min.min(max);
322                let b = 0.5 * (pmax - pmin);
323                if b > 0.0 && parameters[i] < pmax && parameters[i] > pmin {
324                    sigma_par[i] = (b * parameters[i].cos() * sigma0[n_free]).abs();
325                    n_free += 1;
326                } else {
327                    sigma_par[i] = parameters[i];
328                }
329            }
330            Constraint::Fixed => sigma_par[i] = parameters[i],
331            _ => {}
332        }
333    }
334    for (i, c) in constraints.iter().enumerate() {
335        match *c {
336            Constraint::Factor { reference, .. }
337            | Constraint::Delta { reference, .. }
338            | Constraint::Sum { reference, .. } => sigma_par[i] = sigma_par[reference],
339            _ => {}
340        }
341    }
342    sigma_par
343}
344
345/// Invert a square row-major matrix via Gauss-Jordan elimination with partial
346/// pivoting. Returns `None` if the matrix is singular.
347///
348/// This stands in for numpy's `numpy.linalg.inv` used by silx `leastsq` (for
349/// `inv(alpha)` in the LM step and `inv(alpha0)` for the covariance).
350pub fn invert_matrix(m: &[Vec<f64>]) -> Option<Vec<Vec<f64>>> {
351    let n = m.len();
352    if n == 0 {
353        return Some(Vec::new());
354    }
355    // Augment [ m | I ].
356    let mut a: Vec<Vec<f64>> = Vec::with_capacity(n);
357    for (i, row) in m.iter().enumerate() {
358        if row.len() != n {
359            return None;
360        }
361        let mut aug = row.clone();
362        aug.extend((0..n).map(|j| if i == j { 1.0 } else { 0.0 }));
363        a.push(aug);
364    }
365    for col in 0..n {
366        // Partial pivot: largest magnitude in this column at/below the diagonal.
367        let mut pivot = col;
368        let mut best = a[col][col].abs();
369        for (r, row) in a.iter().enumerate().skip(col + 1) {
370            let v = row[col].abs();
371            if v > best {
372                best = v;
373                pivot = r;
374            }
375        }
376        if best == 0.0 {
377            return None; // singular
378        }
379        a.swap(col, pivot);
380        let pivot_val = a[col][col];
381        for v in a[col].iter_mut() {
382            *v /= pivot_val;
383        }
384        let pivot_row = a[col].clone();
385        for (r, row) in a.iter_mut().enumerate() {
386            if r == col {
387                continue;
388            }
389            let factor = row[col];
390            if factor != 0.0 {
391                for (cell, &pv) in row.iter_mut().zip(pivot_row.iter()) {
392                    *cell -= factor * pv;
393                }
394            }
395        }
396    }
397    // Extract the right half.
398    let inv = a
399        .into_iter()
400        .map(|row| row[n..].to_vec())
401        .collect::<Vec<_>>();
402    Some(inv)
403}
404
405/// Default `deltachi` (relative chi-square decrement, in percent) that stops
406/// the LM iteration when an accepted step improves chi-square by less than
407/// this. silx default is `0.001` (i.e. 0.1 %).
408pub const DEFAULT_DELTACHI: f64 = 0.001;
409
410/// Default maximum number of iterations (silx `max_iter=100`).
411pub const DEFAULT_MAX_ITER: usize = 100;
412
413/// Run an iterative Levenberg-Marquardt least-squares fit.
414///
415/// `model(x, params) -> y_hat` is evaluated over the whole `x` array. `p0` is
416/// the initial parameter guess. `sigma` is the optional per-point uncertainty
417/// used as weight (`weight = 1/sigma^2`); when `None`, every weight is 1, as in
418/// silx (`sigma = numpy.ones(...)`).
419///
420/// Faithful to silx `leastsq` (unconstrained path): forward numerical
421/// derivatives with step `delta[i] = (p[i] + (p[i]==0)) * sqrt(epsfcn)`,
422/// `epsfcn = f64::EPSILON`, accept-if-chi-square-decreases with `flambda`
423/// damping (start 0.001, `*10` on rejection up to 1000, `/10` on acceptance),
424/// and the two-stop convergence test (`lastdeltachi < deltachi` or
425/// `absdeltachi < sqrt(epsfcn)`), with the silx rule that the first iteration
426/// always proceeds regardless of those limits.
427pub fn leastsq<F>(
428    model: F,
429    xdata: &[f64],
430    ydata: &[f64],
431    p0: &[f64],
432    sigma: Option<&[f64]>,
433    max_iter: usize,
434    deltachi: f64,
435) -> Result<LeastSqResult, FitError>
436where
437    F: Fn(&[f64], &[f64]) -> Vec<f64>,
438{
439    if xdata.len() != ydata.len() {
440        return Err(FitError::LengthMismatch);
441    }
442    let n_param = p0.len();
443    if n_param == 0 {
444        return Err(FitError::NoFreeParameters);
445    }
446    let m = ydata.len();
447    if m < n_param {
448        return Err(FitError::NotEnoughData);
449    }
450    // check_finite: silx asarray_chkfinite on xdata/ydata/sigma.
451    if xdata.iter().chain(ydata.iter()).any(|v| !v.is_finite()) {
452        return Err(FitError::NonFinite);
453    }
454    // weight0 = (1/sigma)^2 ; sigma==0 → divisor 1 (silx `sigma + (sigma==0)`).
455    let weight0: Vec<f64> = match sigma {
456        Some(s) => {
457            if s.len() != m {
458                return Err(FitError::LengthMismatch);
459            }
460            if s.iter().any(|v| !v.is_finite()) {
461                return Err(FitError::NonFinite);
462            }
463            s.iter()
464                .map(|&sv| {
465                    let denom = if sv == 0.0 { 1.0 } else { sv };
466                    let w = 1.0 / denom;
467                    w * w
468                })
469                .collect()
470        }
471        None => vec![1.0; m],
472    };
473
474    let epsfcn = f64::EPSILON;
475    let sqrt_epsfcn = epsfcn.sqrt();
476
477    let mut fittedpar = p0.to_vec();
478    let mut flambda = 0.001_f64;
479    let mut iiter = max_iter as i64;
480    let mut last_evaluation: Option<Vec<f64>> = None;
481    let mut iteration_counter: usize = 0;
482    let mut nfev: usize = 0;
483
484    // Outputs of the most recent chisq_alpha_beta, captured for covariance.
485    let mut chisq0: f64;
486    let mut alpha0: Vec<Vec<f64>> = vec![vec![0.0; n_param]; n_param];
487
488    loop {
489        if iiter <= 0 {
490            break;
491        }
492        iteration_counter += 1;
493
494        // --- chisq_alpha_beta (unconstrained) ---
495        // yfit at current parameters (reuse last_evaluation if available).
496        let yfit0 = match &last_evaluation {
497            Some(ev) => ev.clone(),
498            None => {
499                let ev = model(xdata, &fittedpar);
500                nfev += 1;
501                ev
502            }
503        };
504        // delta[i] = (p[i] + (p[i]==0)) * sqrt(epsfcn)
505        let delta: Vec<f64> = fittedpar
506            .iter()
507            .map(|&p| (p + if p == 0.0 { 1.0 } else { 0.0 }) * sqrt_epsfcn)
508            .collect();
509        // Forward numerical derivatives deriv[i][j] = (f(p+delta_i) - f0)/delta_i.
510        let mut deriv: Vec<Vec<f64>> = Vec::with_capacity(n_param);
511        for i in 0..n_param {
512            let mut pwork = fittedpar.clone();
513            pwork[i] = fittedpar[i] + delta[i];
514            let f1 = model(xdata, &pwork);
515            nfev += 1;
516            let di = delta[i];
517            let row: Vec<f64> = f1
518                .iter()
519                .zip(yfit0.iter())
520                .map(|(&a, &b)| (a - b) / di)
521                .collect();
522            deriv.push(row);
523        }
524        // deltay = y - yfit ; help0 = weight * deltay
525        let deltay: Vec<f64> = ydata
526            .iter()
527            .zip(yfit0.iter())
528            .map(|(&y, &f)| y - f)
529            .collect();
530        let help0: Vec<f64> = weight0
531            .iter()
532            .zip(deltay.iter())
533            .map(|(&w, &d)| w * d)
534            .collect();
535        // beta[i] = sum_j help0[j]*deriv[i][j]
536        let mut beta = vec![0.0_f64; n_param];
537        for i in 0..n_param {
538            let mut s = 0.0;
539            for j in 0..m {
540                s += help0[j] * deriv[i][j];
541            }
542            beta[i] = s;
543        }
544        // alpha[i][k] = sum_j deriv[i][j]*weight[j]*deriv[k][j]
545        let mut alpha = vec![vec![0.0_f64; n_param]; n_param];
546        for i in 0..n_param {
547            for k in 0..n_param {
548                let mut s = 0.0;
549                for j in 0..m {
550                    s += deriv[i][j] * weight0[j] * deriv[k][j];
551                }
552                alpha[i][k] = s;
553            }
554        }
555        // chisq = sum(help0 * deltay)
556        chisq0 = help0.iter().zip(deltay.iter()).map(|(&h, &d)| h * d).sum();
557        alpha0 = alpha.clone();
558
559        // --- LM inner loop: pick a step that decreases chisq ---
560        loop {
561            // alpha' = alpha0 * (1 + flambda*I): only the diagonal is scaled.
562            let mut alpha_lm = alpha0.clone();
563            for (d, row) in alpha_lm.iter_mut().enumerate() {
564                row[d] *= 1.0 + flambda;
565            }
566            let inv_alpha = match invert_matrix(&alpha_lm) {
567                Some(inv) => inv,
568                None => {
569                    // Treat as a rejected step: damp harder.
570                    flambda *= 10.0;
571                    if flambda > 1000.0 {
572                        iiter = 0;
573                        break;
574                    }
575                    continue;
576                }
577            };
578            // deltapar = beta · inv_alpha  (row-vector times matrix)
579            // numpy: numpy.dot(beta, inv(alpha)) → sum_i beta[i]*inv[i][k]
580            let mut deltapar = vec![0.0_f64; n_param];
581            for (k, dp) in deltapar.iter_mut().enumerate() {
582                let mut s = 0.0;
583                for (i, &b) in beta.iter().enumerate() {
584                    s += b * inv_alpha[i][k];
585                }
586                *dp = s;
587            }
588            let newpar: Vec<f64> = fittedpar
589                .iter()
590                .zip(deltapar.iter())
591                .map(|(&p, &d)| p + d)
592                .collect();
593            let yfit = model(xdata, &newpar);
594            nfev += 1;
595            let chisq: f64 = weight0
596                .iter()
597                .zip(ydata.iter().zip(yfit.iter()))
598                .map(|(&w, (&y, &f))| {
599                    let r = y - f;
600                    w * r * r
601                })
602                .sum();
603            let absdeltachi = chisq0 - chisq;
604            if absdeltachi < 0.0 {
605                // Step worsened chi-square: reject, damp harder (silx flambda *= 10).
606                flambda *= 10.0;
607                if flambda > 1000.0 {
608                    iiter = 0;
609                    break;
610                }
611            } else {
612                // Step improved chi-square: accept it.
613                fittedpar = newpar;
614                let lastdeltachi =
615                    100.0 * (absdeltachi / (chisq + if chisq == 0.0 { 1.0 } else { 0.0 }));
616                // silx convergence test: after the first iteration (which is
617                // always allowed to proceed), stop when either the relative
618                // chi-square decrement falls below `deltachi` OR the absolute
619                // decrement falls below `sqrt(epsfcn)`. Both branches stop the
620                // loop, so they are combined here.
621                if iteration_counter >= 2 && (lastdeltachi < deltachi || absdeltachi < sqrt_epsfcn)
622                {
623                    iiter = 0;
624                }
625                // silx sets `chisq0 = chisq` here, but it is recomputed from
626                // scratch at the top of every outer iteration via the
627                // chisq_alpha_beta block, so persisting it has no effect.
628                flambda /= 10.0;
629                last_evaluation = Some(yfit);
630                break;
631            }
632        }
633        iiter -= 1;
634    }
635
636    // Covariance is inv(alpha0) (silx cov0).
637    let covariance = invert_matrix(&alpha0).ok_or(FitError::SingularMatrix)?;
638    let chisq_final = {
639        // Recompute at the final parameters for a definite chisq value.
640        let yfit = model(xdata, &fittedpar);
641        nfev += 1;
642        weight0
643            .iter()
644            .zip(ydata.iter().zip(yfit.iter()))
645            .map(|(&w, (&y, &f))| {
646                let r = y - f;
647                w * r * r
648            })
649            .sum::<f64>()
650    };
651    let dof = m as i64 - n_param as i64;
652    let reduced_chisq = if dof > 0 {
653        Some(chisq_final / dof as f64)
654    } else {
655        None
656    };
657
658    // silx: constraints is None => uncertainties = sigma0 = sqrt(abs(diag(cov0))).
659    let uncertainties: Vec<f64> = (0..n_param)
660        .map(|i| covariance[i][i].abs().sqrt())
661        .collect();
662
663    Ok(LeastSqResult {
664        parameters: fittedpar,
665        covariance,
666        uncertainties,
667        chisq: chisq_final,
668        reduced_chisq,
669        niter: iteration_counter,
670        nfev,
671    })
672}
673
674/// Gather elements of `v` at the given indices (numpy `take`).
675fn take(v: &[f64], indices: &[usize]) -> Vec<f64> {
676    indices.iter().map(|&i| v[i]).collect()
677}
678
679/// Output of one constrained `chisq_alpha_beta` evaluation.
680struct CabOut {
681    /// `sum(weight * (y - yfit)^2)` at the current parameters.
682    chisq: f64,
683    /// Curvature matrix over the free parameters (`n_free x n_free`).
684    alpha: Vec<Vec<f64>>,
685    /// Gradient vector over the free parameters (`n_free`).
686    beta: Vec<f64>,
687    /// Number of free parameters this evaluation fitted.
688    n_free: usize,
689    /// Full-parameter index of each free parameter (silx `free_index`).
690    free_index: Vec<usize>,
691    /// Full-parameter indices passed to the model (silx `noigno`).
692    noigno: Vec<usize>,
693    /// Current values of the free parameters (silx `fitparam`).
694    fitparam: Vec<f64>,
695}
696
697/// One constrained curvature/gradient evaluation (silx `chisq_alpha_beta` with
698/// a non-None `constraints`). Builds the free-parameter set, the numerical
699/// forward-difference Jacobian scaled by each parameter's `derivfactor`, and the
700/// normal-equation matrices restricted to the free parameters.
701#[allow(clippy::too_many_arguments)]
702fn chisq_alpha_beta_constrained<F>(
703    model: &F,
704    parameters: &[f64],
705    xdata: &[f64],
706    ydata: &[f64],
707    weight0: &[f64],
708    constraints: &[Constraint],
709    sqrt_epsfcn: f64,
710    last_evaluation: Option<&[f64]>,
711    nfev: &mut usize,
712) -> CabOut
713where
714    F: Fn(&[f64], &[f64]) -> Vec<f64>,
715{
716    let m = ydata.len();
717
718    // Classify parameters into the free set (silx: CFREE/CPOSITIVE always free,
719    // CQUOTED free only when in bounds; others held/derived/ignored).
720    let mut fitparam: Vec<f64> = Vec::new();
721    let mut free_index: Vec<usize> = Vec::new();
722    let mut noigno: Vec<usize> = Vec::new();
723    let mut derivfactor: Vec<f64> = Vec::new();
724    for (i, c) in constraints.iter().enumerate() {
725        if !matches!(c, Constraint::Ignored) {
726            noigno.push(i);
727        }
728        match *c {
729            Constraint::Free => {
730                fitparam.push(parameters[i]);
731                derivfactor.push(1.0);
732                free_index.push(i);
733            }
734            Constraint::Positive => {
735                fitparam.push(parameters[i].abs());
736                derivfactor.push(1.0);
737                free_index.push(i);
738            }
739            Constraint::Quoted { min, max } => {
740                let pmax = min.max(max);
741                let pmin = min.min(max);
742                if (pmax - pmin) > 0.0 && parameters[i] <= pmax && parameters[i] >= pmin {
743                    let a = 0.5 * (pmax + pmin);
744                    let b = 0.5 * (pmax - pmin);
745                    fitparam.push(parameters[i]);
746                    derivfactor.push(b * ((parameters[i] - a) / b).asin().cos());
747                    free_index.push(i);
748                }
749                // Out of bounds: kept at its start value (not fitted).
750            }
751            _ => {}
752        }
753    }
754    let n_free = fitparam.len();
755
756    // delta[i] = (fitparam[i] + (fitparam[i]==0)) * sqrt(epsfcn).
757    let delta: Vec<f64> = fitparam
758        .iter()
759        .map(|&p| (p + if p == 0.0 { 1.0 } else { 0.0 }) * sqrt_epsfcn)
760        .collect();
761
762    // pwork is the full parameter vector with the free values substituted in.
763    let mut pwork = parameters.to_vec();
764    for (i, &fi) in free_index.iter().enumerate() {
765        pwork[fi] = fitparam[i];
766    }
767
768    // Base evaluation (silx f2 / yfit). We use one constraint-expanded base for
769    // both the derivative reference and the residual, so the forward difference
770    // is self-consistent; this matches silx's `model(*parameters)` exactly when
771    // nothing is ignored and the start is in-domain (the practical case).
772    let yfit: Vec<f64> = match last_evaluation {
773        Some(ev) => ev.to_vec(),
774        None => {
775            let base_in = take(&get_parameters(&pwork, constraints), &noigno);
776            let ev = model(xdata, &base_in);
777            *nfev += 1;
778            ev
779        }
780    };
781
782    // Numerical forward derivatives for each free parameter, scaled by
783    // derivfactor (CQUOTED `B·cos`, else 1).
784    let mut deriv: Vec<Vec<f64>> = Vec::with_capacity(n_free);
785    for i in 0..n_free {
786        let fi = free_index[i];
787        pwork[fi] = fitparam[i] + delta[i];
788        let newpar = take(&get_parameters(&pwork, constraints), &noigno);
789        let f1 = model(xdata, &newpar);
790        *nfev += 1;
791        let di = delta[i];
792        let df = derivfactor[i];
793        let row: Vec<f64> = f1
794            .iter()
795            .zip(yfit.iter())
796            .map(|(&a, &b)| (a - b) / di * df)
797            .collect();
798        deriv.push(row);
799        pwork[fi] = fitparam[i]; // restore
800    }
801
802    let deltay: Vec<f64> = ydata
803        .iter()
804        .zip(yfit.iter())
805        .map(|(&y, &f)| y - f)
806        .collect();
807    let help0: Vec<f64> = weight0
808        .iter()
809        .zip(deltay.iter())
810        .map(|(&w, &d)| w * d)
811        .collect();
812    let mut beta = vec![0.0_f64; n_free];
813    for (i, b) in beta.iter_mut().enumerate() {
814        let mut s = 0.0;
815        for j in 0..m {
816            s += help0[j] * deriv[i][j];
817        }
818        *b = s;
819    }
820    let mut alpha = vec![vec![0.0_f64; n_free]; n_free];
821    for i in 0..n_free {
822        for k in 0..n_free {
823            let mut s = 0.0;
824            for j in 0..m {
825                s += deriv[i][j] * weight0[j] * deriv[k][j];
826            }
827            alpha[i][k] = s;
828        }
829    }
830    let chisq = help0.iter().zip(deltay.iter()).map(|(&h, &d)| h * d).sum();
831
832    CabOut {
833        chisq,
834        alpha,
835        beta,
836        n_free,
837        free_index,
838        noigno,
839        fitparam,
840    }
841}
842
843/// Run a constrained Levenberg-Marquardt least-squares fit (silx `leastsq` with
844/// a non-`None` `constraints`).
845///
846/// `constraints` has one entry per parameter in `p0`. Free / Positive /
847/// in-bounds Quoted parameters are varied; Fixed parameters are held at their
848/// start (and receive 100 % uncertainty); Factor / Delta / Sum parameters are
849/// derived from another parameter each step; Ignored parameters are set to 0 and
850/// dropped from the model call (the model must accept the reduced count). When
851/// every entry is [`Constraint::Free`] this matches an unconstrained fit except
852/// that the covariance is recomputed at the final parameters (silx's second
853/// `chisq_alpha_beta` pass).
854///
855/// The non-finite check, weighting, LM damping and convergence test match
856/// [`leastsq`]; only the per-step parameter handling differs.
857#[allow(clippy::too_many_arguments)]
858pub fn leastsq_constrained<F>(
859    model: F,
860    xdata: &[f64],
861    ydata: &[f64],
862    p0: &[f64],
863    constraints: &[Constraint],
864    sigma: Option<&[f64]>,
865    max_iter: usize,
866    deltachi: f64,
867) -> Result<LeastSqResult, FitError>
868where
869    F: Fn(&[f64], &[f64]) -> Vec<f64>,
870{
871    if xdata.len() != ydata.len() {
872        return Err(FitError::LengthMismatch);
873    }
874    let n_param = p0.len();
875    if n_param == 0 {
876        return Err(FitError::NoFreeParameters);
877    }
878    if constraints.len() != n_param {
879        return Err(FitError::BadConstraintReference);
880    }
881    let m = ydata.len();
882    if m < 1 {
883        return Err(FitError::NotEnoughData);
884    }
885    if xdata.iter().chain(ydata.iter()).any(|v| !v.is_finite()) {
886        return Err(FitError::NonFinite);
887    }
888    // Validate Factor/Delta/Sum references and reject equal-bound Quoted.
889    for c in constraints {
890        match *c {
891            Constraint::Factor { reference, .. }
892            | Constraint::Delta { reference, .. }
893            | Constraint::Sum { reference, .. } => {
894                if reference >= n_param {
895                    return Err(FitError::BadConstraintReference);
896                }
897            }
898            Constraint::Quoted { min, max } => {
899                if (min.max(max) - min.min(max)) == 0.0 {
900                    return Err(FitError::InvalidConstraint);
901                }
902            }
903            _ => {}
904        }
905    }
906
907    let weight0: Vec<f64> = match sigma {
908        Some(s) => {
909            if s.len() != m {
910                return Err(FitError::LengthMismatch);
911            }
912            if s.iter().any(|v| !v.is_finite()) {
913                return Err(FitError::NonFinite);
914            }
915            s.iter()
916                .map(|&sv| {
917                    let denom = if sv == 0.0 { 1.0 } else { sv };
918                    let w = 1.0 / denom;
919                    w * w
920                })
921                .collect()
922        }
923        None => vec![1.0; m],
924    };
925
926    let epsfcn = f64::EPSILON;
927    let sqrt_epsfcn = epsfcn.sqrt();
928
929    // Count the initial free set so `alpha0` is sized even if no iteration runs
930    // (max_iter == 0); also enforces silx's "No free parameters to fit".
931    let n_free_initial = constraints
932        .iter()
933        .enumerate()
934        .filter(|(i, c)| match **c {
935            Constraint::Free | Constraint::Positive => true,
936            Constraint::Quoted { min, max } => {
937                let (pmax, pmin) = (min.max(max), min.min(max));
938                (pmax - pmin) > 0.0 && p0[*i] <= pmax && p0[*i] >= pmin
939            }
940            _ => false,
941        })
942        .count();
943    if n_free_initial == 0 {
944        return Err(FitError::NoFreeParameters);
945    }
946
947    let mut fittedpar = p0.to_vec();
948    let mut flambda = 0.001_f64;
949    let mut iiter = max_iter as i64;
950    let mut last_evaluation: Option<Vec<f64>> = None;
951    let mut iteration_counter: usize = 0;
952    let mut nfev: usize = 0;
953
954    let mut alpha0: Vec<Vec<f64>> = vec![vec![0.0; n_free_initial]; n_free_initial];
955    let mut n_free_final = n_free_initial;
956
957    loop {
958        if iiter <= 0 {
959            break;
960        }
961        iteration_counter += 1;
962
963        let cab = chisq_alpha_beta_constrained(
964            &model,
965            &fittedpar,
966            xdata,
967            ydata,
968            &weight0,
969            constraints,
970            sqrt_epsfcn,
971            last_evaluation.as_deref(),
972            &mut nfev,
973        );
974        let chisq0 = cab.chisq;
975        alpha0 = cab.alpha.clone();
976        n_free_final = cab.n_free;
977        let beta = &cab.beta;
978        let free_index = &cab.free_index;
979        let noigno = &cab.noigno;
980        let fitparam = &cab.fitparam;
981        if cab.n_free == 0 {
982            return Err(FitError::NoFreeParameters);
983        }
984
985        loop {
986            let mut alpha_lm = alpha0.clone();
987            for (d, row) in alpha_lm.iter_mut().enumerate() {
988                row[d] *= 1.0 + flambda;
989            }
990            let inv_alpha = match invert_matrix(&alpha_lm) {
991                Some(inv) => inv,
992                None => {
993                    flambda *= 10.0;
994                    if flambda > 1000.0 {
995                        iiter = 0;
996                        break;
997                    }
998                    continue;
999                }
1000            };
1001            // deltapar = beta · inv_alpha (free-parameter space).
1002            let mut deltapar = vec![0.0_f64; cab.n_free];
1003            for (k, dp) in deltapar.iter_mut().enumerate() {
1004                let mut s = 0.0;
1005                for (i, &b) in beta.iter().enumerate() {
1006                    s += b * inv_alpha[i][k];
1007                }
1008                *dp = s;
1009            }
1010            // Rebuild the full parameter vector: Fixed/derived entries come from
1011            // the original p0 template, free entries take the LM step (Quoted via
1012            // the sin reparametrisation), then get_parameters applies the
1013            // dependent relations.
1014            let mut newpar = p0.to_vec();
1015            for (i, &fi) in free_index.iter().enumerate() {
1016                let pv = match constraints[fi] {
1017                    Constraint::Quoted { min, max } => {
1018                        let pmax = min.max(max);
1019                        let pmin = min.min(max);
1020                        let a = 0.5 * (pmax + pmin);
1021                        let b = 0.5 * (pmax - pmin);
1022                        a + b * (((fitparam[i] - a) / b).asin() + deltapar[i]).sin()
1023                    }
1024                    // Free and Positive both step additively; Positive's abs is
1025                    // applied by get_parameters below.
1026                    _ => fitparam[i] + deltapar[i],
1027                };
1028                newpar[fi] = pv;
1029            }
1030            let newpar = get_parameters(&newpar, constraints);
1031            let workpar = take(&newpar, noigno);
1032            let yfit = model(xdata, &workpar);
1033            nfev += 1;
1034            let chisq: f64 = weight0
1035                .iter()
1036                .zip(ydata.iter().zip(yfit.iter()))
1037                .map(|(&w, (&y, &f))| {
1038                    let r = y - f;
1039                    w * r * r
1040                })
1041                .sum();
1042            let absdeltachi = chisq0 - chisq;
1043            if absdeltachi < 0.0 {
1044                flambda *= 10.0;
1045                if flambda > 1000.0 {
1046                    iiter = 0;
1047                    break;
1048                }
1049            } else {
1050                fittedpar = newpar;
1051                let lastdeltachi =
1052                    100.0 * (absdeltachi / (chisq + if chisq == 0.0 { 1.0 } else { 0.0 }));
1053                if iteration_counter >= 2 && (lastdeltachi < deltachi || absdeltachi < sqrt_epsfcn)
1054                {
1055                    iiter = 0;
1056                }
1057                flambda /= 10.0;
1058                last_evaluation = Some(yfit);
1059                break;
1060            }
1061        }
1062        iiter -= 1;
1063    }
1064
1065    // cov0 = inv(alpha0): the free-space covariance (silx).
1066    let cov0 = invert_matrix(&alpha0).ok_or(FitError::SingularMatrix)?;
1067
1068    // Second pass: every non-fixed/ignored parameter becomes Free, recompute the
1069    // curvature at the final parameters and invert for the reported covariance;
1070    // fixed/ignored parameters get a zero row/col and a 100 % diagonal.
1071    let new_constraints: Vec<Constraint> = constraints
1072        .iter()
1073        .map(|c| match c {
1074            Constraint::Fixed | Constraint::Ignored => *c,
1075            _ => Constraint::Free,
1076        })
1077        .collect();
1078    let cab2 = chisq_alpha_beta_constrained(
1079        &model,
1080        &fittedpar,
1081        xdata,
1082        ydata,
1083        &weight0,
1084        &new_constraints,
1085        sqrt_epsfcn,
1086        last_evaluation.as_deref(),
1087        &mut nfev,
1088    );
1089    let mut covariance = vec![vec![0.0_f64; n_param]; n_param];
1090    if let Some(cov_free) = invert_matrix(&cab2.alpha) {
1091        for (r, &pr) in cab2.free_index.iter().enumerate() {
1092            for (cc, &pc) in cab2.free_index.iter().enumerate() {
1093                covariance[pr][pc] = cov_free[r][cc];
1094            }
1095        }
1096    }
1097    for (idx, c) in constraints.iter().enumerate() {
1098        if matches!(c, Constraint::Fixed | Constraint::Ignored) {
1099            covariance[idx][idx] = fittedpar[idx] * fittedpar[idx];
1100        }
1101    }
1102
1103    // Uncertainties: sigma0 = sqrt(abs(diag(cov0))) over the free parameters,
1104    // propagated through the constraints (silx _get_sigma_parameters).
1105    let sigma0: Vec<f64> = (0..n_free_final).map(|i| cov0[i][i].abs().sqrt()).collect();
1106    let uncertainties = get_sigma_parameters(&fittedpar, &sigma0, constraints);
1107
1108    // Final chi-square at the converged parameters.
1109    let workpar = take(&get_parameters(&fittedpar, constraints), &cab2.noigno);
1110    let yfit_final = model(xdata, &workpar);
1111    nfev += 1;
1112    let chisq_final: f64 = weight0
1113        .iter()
1114        .zip(ydata.iter().zip(yfit_final.iter()))
1115        .map(|(&w, (&y, &f))| {
1116            let r = y - f;
1117            w * r * r
1118        })
1119        .sum();
1120    let dof = m as i64 - n_free_final as i64;
1121    let reduced_chisq = if dof > 0 {
1122        Some(chisq_final / dof as f64)
1123    } else {
1124        None
1125    };
1126
1127    Ok(LeastSqResult {
1128        parameters: fittedpar,
1129        covariance,
1130        uncertainties,
1131        chisq: chisq_final,
1132        reduced_chisq,
1133        niter: iteration_counter,
1134        nfev,
1135    })
1136}
1137
1138// ---------------------------------------------------------------------------
1139// Peak models (CPU). Each evaluates a single peak + flat background:
1140// y(x) = peak(x; params...) + background.
1141//
1142// The peak formulas are ported byte-for-byte from
1143// `silx/math/fit/functions/src/funs.c` (single-peak case of the sum_* loops).
1144// A trailing `background` parameter is appended (constant offset) so that a
1145// model is fully described by one parameter vector for `leastsq`.
1146// ---------------------------------------------------------------------------
1147
1148/// Evaluate a Gaussian peak (height parameterisation) plus flat background.
1149///
1150/// `params = [height, centroid, fwhm, background]`. Mirrors C `sum_gauss`:
1151/// `sigma = fwhm / (2*sqrt(2*LOG2))`, `y = height*exp(-0.5*((x-c)/sigma)^2)`,
1152/// with the C guard `(x-c)/sigma <= 20` skipping far-tail terms.
1153pub fn gaussian_model(x: &[f64], params: &[f64]) -> Vec<f64> {
1154    let (height, centroid, fwhm, bg) = (params[0], params[1], params[2], params[3]);
1155    let sigma = fwhm / fwhm_to_sigma_factor();
1156    x.iter()
1157        .map(|&xi| {
1158            let mut y = bg;
1159            if sigma != 0.0 {
1160                let dhelp = (xi - centroid) / sigma;
1161                if dhelp <= 20.0 {
1162                    y += height * (-0.5 * dhelp * dhelp).exp();
1163                }
1164            }
1165            y
1166        })
1167        .collect()
1168}
1169
1170/// Evaluate a Gaussian peak (area parameterisation) plus flat background.
1171///
1172/// `params = [area, centroid, fwhm, background]`. Mirrors C `sum_agauss`:
1173/// `sigma = fwhm/(2*sqrt(2*LOG2))`, `height = area/(sigma*sqrt(2*pi))`,
1174/// with the C guard `(x-c)/sigma <= 35`.
1175pub fn gaussian_area_model(x: &[f64], params: &[f64]) -> Vec<f64> {
1176    let (area, centroid, fwhm, bg) = (params[0], params[1], params[2], params[3]);
1177    let sigma = fwhm / fwhm_to_sigma_factor();
1178    let sqrt2pi = (2.0 * std::f64::consts::PI).sqrt();
1179    x.iter()
1180        .map(|&xi| {
1181            let mut y = bg;
1182            if sigma != 0.0 {
1183                let height = area / (sigma * sqrt2pi);
1184                let dhelp = (xi - centroid) / sigma;
1185                if dhelp <= 35.0 {
1186                    y += height * (-0.5 * dhelp * dhelp).exp();
1187                }
1188            }
1189            y
1190        })
1191        .collect()
1192}
1193
1194/// Evaluate a Lorentzian peak (height parameterisation) plus flat background.
1195///
1196/// `params = [height, centroid, fwhm, background]`. Mirrors C `sum_lorentz`:
1197/// `dhelp = (x-c)/(0.5*fwhm)`, `y = height/(1 + dhelp^2)`.
1198pub fn lorentzian_model(x: &[f64], params: &[f64]) -> Vec<f64> {
1199    let (height, centroid, fwhm, bg) = (params[0], params[1], params[2], params[3]);
1200    x.iter()
1201        .map(|&xi| {
1202            let mut y = bg;
1203            if fwhm != 0.0 {
1204                let dhelp = (xi - centroid) / (0.5 * fwhm);
1205                y += height / (1.0 + dhelp * dhelp);
1206            }
1207            y
1208        })
1209        .collect()
1210}
1211
1212/// Evaluate a pseudo-Voigt peak (height parameterisation) plus flat background.
1213///
1214/// `params = [height, centroid, fwhm, eta, background]`. Mirrors C
1215/// `sum_pvoigt`: `PV = eta*L + (1-eta)*G` where `L = height/(1+((x-c)/(0.5*fwhm))^2)`
1216/// and `G = height*exp(-0.5*((x-c)/sigma)^2)` with `sigma = fwhm/(2*sqrt(2*LOG2))`,
1217/// C guard `(x-c)/sigma <= 35` on the Gaussian term.
1218pub fn pseudo_voigt_model(x: &[f64], params: &[f64]) -> Vec<f64> {
1219    let (height, centroid, fwhm, eta, bg) = (params[0], params[1], params[2], params[3], params[4]);
1220    let sigma = fwhm / fwhm_to_sigma_factor();
1221    x.iter()
1222        .map(|&xi| {
1223            let mut y = bg;
1224            if fwhm != 0.0 {
1225                // Lorentzian term.
1226                let dl = (xi - centroid) / (0.5 * fwhm);
1227                y += eta * height / (1.0 + dl * dl);
1228            }
1229            if sigma != 0.0 {
1230                // Gaussian term.
1231                let dg = (xi - centroid) / sigma;
1232                if dg <= 35.0 {
1233                    y += (1.0 - eta) * height * (-0.5 * dg * dg).exp();
1234                }
1235            }
1236            y
1237        })
1238        .collect()
1239}
1240
1241// ---------------------------------------------------------------------------
1242// Step / slit models (non-peak fit theories).
1243//
1244// Ported from `silx/math/fit/functions/src/funs.c` (`sum_stepdown`,
1245// `sum_stepup`, `sum_slit`) and the pure-Python `atan_stepup`
1246// (`silx/math/fit/functions/functions.pyx`). As with the peak models above, a
1247// trailing constant `background` parameter is appended so each model is a
1248// complete `leastsq` target; silx keeps the baseline in a separate background
1249// theory.
1250//
1251// `erf`/`erfc` are not in Rust's std. They are approximated with
1252// Abramowitz & Stegun 7.1.26 (`|error| <= 1.5e-7`). silx calls the C library
1253// `erf`/`erfc` at full double precision; the difference is far below fit noise
1254// but is documented here rather than hidden.
1255// ---------------------------------------------------------------------------
1256
1257/// Gaussian error function via Abramowitz & Stegun 7.1.26 (`|error| <= 1.5e-7`).
1258/// Exact at `x == 0` (returns `0`) and odd-symmetric, so the step models hit
1259/// their half-height exactly at the centre.
1260fn erf(x: f64) -> f64 {
1261    if x == 0.0 {
1262        return 0.0;
1263    }
1264    let sign = if x < 0.0 { -1.0 } else { 1.0 };
1265    let x = x.abs();
1266    // A&S 7.1.26 coefficients.
1267    const A1: f64 = 0.254829592;
1268    const A2: f64 = -0.284496736;
1269    const A3: f64 = 1.421413741;
1270    const A4: f64 = -1.453152027;
1271    const A5: f64 = 1.061405429;
1272    const P: f64 = 0.3275911;
1273    let t = 1.0 / (1.0 + P * x);
1274    let poly = ((((A5 * t + A4) * t + A3) * t + A2) * t + A1) * t;
1275    sign * (1.0 - poly * (-x * x).exp())
1276}
1277
1278/// Complementary error function, `1 - erf(x)`.
1279fn erfc(x: f64) -> f64 {
1280    1.0 - erf(x)
1281}
1282
1283/// The C `sum_step*` edge scale: `denom = fwhm * sqrt(2) / (2*sqrt(2*LOG2))`,
1284/// i.e. `sigma * sqrt(2)`. The erf argument is `(x - centre) / denom`.
1285fn step_denom(fwhm: f64) -> f64 {
1286    fwhm * std::f64::consts::SQRT_2 / fwhm_to_sigma_factor()
1287}
1288
1289/// Evaluate a step-down (descending error-function edge) plus flat background.
1290///
1291/// `params = [height, centroid, fwhm, background]`. Mirrors C `sum_stepdown`:
1292/// `y = background + height * 0.5 * erfc((x - centroid) / denom)` where `denom`
1293/// is `step_denom`. `fwhm` is the full-width at half maximum of the step's
1294/// derivative (its sharpness).
1295pub fn stepdown_model(x: &[f64], params: &[f64]) -> Vec<f64> {
1296    let (height, centroid, fwhm, bg) = (params[0], params[1], params[2], params[3]);
1297    let denom = step_denom(fwhm);
1298    x.iter()
1299        .map(|&xi| {
1300            let mut y = bg;
1301            if denom != 0.0 {
1302                y += height * 0.5 * erfc((xi - centroid) / denom);
1303            }
1304            y
1305        })
1306        .collect()
1307}
1308
1309/// Evaluate a step-up (ascending error-function edge) plus flat background.
1310///
1311/// `params = [height, centroid, fwhm, background]`. Mirrors C `sum_stepup`:
1312/// `y = background + height * 0.5 * (1 + erf((x - centroid) / denom))`.
1313pub fn stepup_model(x: &[f64], params: &[f64]) -> Vec<f64> {
1314    let (height, centroid, fwhm, bg) = (params[0], params[1], params[2], params[3]);
1315    let denom = step_denom(fwhm);
1316    x.iter()
1317        .map(|&xi| {
1318            let mut y = bg;
1319            if denom != 0.0 {
1320                y += height * 0.5 * (1.0 + erf((xi - centroid) / denom));
1321            }
1322            y
1323        })
1324        .collect()
1325}
1326
1327/// Evaluate a slit (a rising then falling pair of edges) plus flat background.
1328///
1329/// `params = [height, position, fwhm, beamfwhm, background]`. Mirrors C
1330/// `sum_slit`: with `c1 = position - 0.5*fwhm`, `c2 = position + 0.5*fwhm` and
1331/// `denom = step_denom(beamfwhm)`,
1332/// `y = background + height * 0.25 * (1 + erf((x-c1)/denom)) * erfc((x-c2)/denom)`.
1333/// `fwhm` is the slit width; `beamfwhm` is the sharpness of its two edges.
1334pub fn slit_model(x: &[f64], params: &[f64]) -> Vec<f64> {
1335    let (height, position, fwhm, beamfwhm, bg) =
1336        (params[0], params[1], params[2], params[3], params[4]);
1337    let denom = step_denom(beamfwhm);
1338    let c1 = position - 0.5 * fwhm;
1339    let c2 = position + 0.5 * fwhm;
1340    x.iter()
1341        .map(|&xi| {
1342            let mut y = bg;
1343            if denom != 0.0 {
1344                y += height * 0.25 * (1.0 + erf((xi - c1) / denom)) * erfc((xi - c2) / denom);
1345            }
1346            y
1347        })
1348        .collect()
1349}
1350
1351/// Evaluate an arctan step-up plus flat background.
1352///
1353/// `params = [height, position, width, background]`. Mirrors Python
1354/// `atan_stepup`: `y = background + height * (0.5 + atan((x - position)/width)/pi)`.
1355/// A lower `width` yields a sharper step.
1356pub fn atan_stepup_model(x: &[f64], params: &[f64]) -> Vec<f64> {
1357    let (height, position, width, bg) = (params[0], params[1], params[2], params[3]);
1358    x.iter()
1359        .map(|&xi| {
1360            let mut y = bg;
1361            if width != 0.0 {
1362                y += height * (0.5 + ((xi - position) / width).atan() / std::f64::consts::PI);
1363            }
1364            y
1365        })
1366        .collect()
1367}
1368
1369// ---------------------------------------------------------------------------
1370// Initial-parameter estimators.
1371//
1372// silx `estimate_height_position_fwhm` runs a peak search + strip background +
1373// a 4-iteration constrained micro-fit. The strip/snip background estimator and
1374// multi-peak search are DEFERRED; we port the single-peak analytical seed:
1375// background = min(y), height = max(y) - background, centroid = x[argmax],
1376// fwhm from the half-maximum crossing (the same shape silx ends up with for a
1377// single dominant peak). Area/eta conversions follow `estimate_agauss` /
1378// `estimate_pvoigt`.
1379// ---------------------------------------------------------------------------
1380
1381/// Analytical single-peak seed: `(height, centroid, fwhm, background)`.
1382///
1383/// `background = min(y)`; `height = max(y) - background`; `centroid` is the
1384/// `x` at the maximum; `fwhm` is the width between the outermost half-maximum
1385/// crossings around the peak. Returns `None` if there are fewer than 3 points
1386/// or lengths differ.
1387pub fn estimate_height_position_fwhm(x: &[f64], y: &[f64]) -> Option<(f64, f64, f64, f64)> {
1388    if x.len() != y.len() || x.len() < 3 {
1389        return None;
1390    }
1391    let bg = y.iter().copied().fold(f64::INFINITY, f64::min);
1392    let mut max_y = f64::NEG_INFINITY;
1393    let mut max_idx = 0;
1394    for (i, &yi) in y.iter().enumerate() {
1395        if yi > max_y {
1396            max_y = yi;
1397            max_idx = i;
1398        }
1399    }
1400    let height = max_y - bg;
1401    let centroid = x[max_idx];
1402    let half_max = bg + height / 2.0;
1403    let mut left = max_idx;
1404    while left > 0 && y[left] > half_max {
1405        left -= 1;
1406    }
1407    let mut right = max_idx;
1408    while right < y.len() - 1 && y[right] > half_max {
1409        right += 1;
1410    }
1411    let fwhm = if right > left {
1412        x[right] - x[left]
1413    } else {
1414        (x[x.len() - 1] - x[0]).abs() / 4.0
1415    };
1416    let fwhm = if fwhm > 0.0 {
1417        fwhm
1418    } else {
1419        (x[x.len() - 1] - x[0]).abs() / 4.0
1420    };
1421    Some((height, centroid, fwhm, bg))
1422}
1423
1424/// Seed for [`gaussian_model`]: `[height, centroid, fwhm, background]`.
1425pub fn estimate_gaussian(x: &[f64], y: &[f64]) -> Option<Vec<f64>> {
1426    let (h, c, f, bg) = estimate_height_position_fwhm(x, y)?;
1427    Some(vec![h, c, f, bg])
1428}
1429
1430/// Seed for [`gaussian_area_model`]: `[area, centroid, fwhm, background]`.
1431///
1432/// Area conversion mirrors silx `estimate_agauss`:
1433/// `area = sqrt(2*pi) * height * fwhm / (2*sqrt(2*ln2))`.
1434pub fn estimate_gaussian_area(x: &[f64], y: &[f64]) -> Option<Vec<f64>> {
1435    let (h, c, f, bg) = estimate_height_position_fwhm(x, y)?;
1436    let area = (2.0 * std::f64::consts::PI).sqrt() * h * f / fwhm_to_sigma_factor();
1437    Some(vec![area, c, f, bg])
1438}
1439
1440/// Seed for [`lorentzian_model`]: `[height, centroid, fwhm, background]`.
1441///
1442/// Same height/position/fwhm seed as Gaussian (silx `estimate_lorentz` reuses
1443/// `estimate_height_position_fwhm` without converting height).
1444pub fn estimate_lorentzian(x: &[f64], y: &[f64]) -> Option<Vec<f64>> {
1445    let (h, c, f, bg) = estimate_height_position_fwhm(x, y)?;
1446    Some(vec![h, c, f, bg])
1447}
1448
1449/// Seed for [`pseudo_voigt_model`]: `[height, centroid, fwhm, eta, background]`.
1450///
1451/// Eta seeds to 0.5, mirroring silx `estimate_pvoigt` (`newpar.append(0.5)`).
1452pub fn estimate_pseudo_voigt(x: &[f64], y: &[f64]) -> Option<Vec<f64>> {
1453    let (h, c, f, bg) = estimate_height_position_fwhm(x, y)?;
1454    Some(vec![h, c, f, 0.5, bg])
1455}
1456
1457/// `numpy.convolve(y, kernel, mode="valid")`: the kernel is applied reversed,
1458/// and the output length is `y.len() - kernel.len() + 1` (empty when `y` is
1459/// shorter than `kernel`).
1460fn convolve_valid(y: &[f64], kernel: &[f64]) -> Vec<f64> {
1461    let (n, m) = (y.len(), kernel.len());
1462    if n < m || m == 0 {
1463        return Vec::new();
1464    }
1465    (0..=n - m)
1466        .map(|k| (0..m).map(|j| y[k + j] * kernel[m - 1 - j]).sum())
1467        .collect()
1468}
1469
1470/// Shared step-edge seed used by [`estimate_stepup`] / [`estimate_stepdown`].
1471///
1472/// silx convolves `y` with an edge-detecting kernel, then takes the dominant
1473/// peak of that derivative as the step centre and width; the height is the data
1474/// amplitude `max(y) - min(y)`. The derivative is *not* rescaled (silx's
1475/// `y_deriv *= max(y)/max(y_deriv)` is a uniform positive scale that leaves the
1476/// argmax and half-maximum crossings — hence centre and fwhm — unchanged).
1477/// Multi-step search is out of scope: the single dominant edge is used, matching
1478/// the peak estimators. The appended `background = min(y)`.
1479fn estimate_step(x: &[f64], y: &[f64], kernel: &[f64]) -> Option<Vec<f64>> {
1480    if x.len() != y.len() || x.len() < 3 {
1481        return None;
1482    }
1483    let bg = y.iter().copied().fold(f64::INFINITY, f64::min);
1484    let max_y = y.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1485    let data_amplitude = max_y - bg;
1486
1487    let cutoff = kernel.len() / 2;
1488    let y_deriv = convolve_valid(y, kernel);
1489    let (center, fwhm) = if y_deriv.len() >= 3 && x.len() > 2 * cutoff {
1490        let x_slice = &x[cutoff..x.len() - cutoff];
1491        match estimate_height_position_fwhm(x_slice, &y_deriv) {
1492            Some((_h, c, f, _b)) => (c, f),
1493            None => step_fallback(x),
1494        }
1495    } else {
1496        step_fallback(x)
1497    };
1498    Some(vec![data_amplitude, center, fwhm, bg])
1499}
1500
1501/// silx no-peak fallback: centre at the middle of `x`, `fwhm = FwhmPoints * dx`
1502/// with the silx default `FwhmPoints = 8`.
1503fn step_fallback(x: &[f64]) -> (f64, f64) {
1504    let center = x[x.len() / 2];
1505    let dx = if x.len() > 1 { x[1] - x[0] } else { 1.0 };
1506    (center, 8.0 * dx)
1507}
1508
1509/// Seed for [`stepup_model`]: `[height, centroid, fwhm, background]`.
1510///
1511/// Mirrors silx `estimate_stepup` (edge kernel `[0.25, 0.75, 0, -0.75, -0.25]`).
1512pub fn estimate_stepup(x: &[f64], y: &[f64]) -> Option<Vec<f64>> {
1513    estimate_step(x, y, &[0.25, 0.75, 0.0, -0.75, -0.25])
1514}
1515
1516/// Seed for [`stepdown_model`]: `[height, centroid, fwhm, background]`.
1517///
1518/// Mirrors silx `estimate_stepdown` (edge kernel `[-0.25, -0.75, 0, 0.75, 0.25]`).
1519pub fn estimate_stepdown(x: &[f64], y: &[f64]) -> Option<Vec<f64>> {
1520    estimate_step(x, y, &[-0.25, -0.75, 0.0, 0.75, 0.25])
1521}
1522
1523/// Seed for [`atan_stepup_model`]: `[height, position, width, background]`.
1524///
1525/// silx uses `estimate_stepup` for the arctan step (the same edge detection;
1526/// the step fwhm seeds the arctan `width`).
1527pub fn estimate_atan_stepup(x: &[f64], y: &[f64]) -> Option<Vec<f64>> {
1528    estimate_stepup(x, y)
1529}
1530
1531/// Seed for [`slit_model`]: `[height, position, fwhm, beamfwhm, background]`.
1532///
1533/// Mirrors silx `estimate_slit`: seed the up- and down-edges to size the beam
1534/// sharpness, then take the slit centre/width from the half-maximum crossings
1535/// of the background-subtracted data. silx subtracts a *strip* background; that
1536/// filter is a separate theory (not yet ported), so the baseline here is
1537/// `min(y)` — the same simplification the peak estimators use. `beamfwhm` is
1538/// seeded as the average of the up/down edge FWHMs (silx's docstring intent;
1539/// its code has an index typo that reads the down-step centre instead), then
1540/// clamped to silx's `[range*3/n, edge_distance/10]` bounds.
1541pub fn estimate_slit(x: &[f64], y: &[f64]) -> Option<Vec<f64>> {
1542    let up = estimate_stepup(x, y)?; // [h, center_up, fwhm_up, bg]
1543    let down = estimate_stepdown(x, y)?; // [h, center_down, fwhm_down, bg]
1544    let (center_up, fwhm_up) = (up[1], up[2]);
1545    let (center_down, fwhm_down) = (down[1], down[2]);
1546    let edge_distance = (center_down - center_up).abs();
1547
1548    let bg = y.iter().copied().fold(f64::INFINITY, f64::min);
1549    let y_minus_bg: Vec<f64> = y.iter().map(|&yi| yi - bg).collect();
1550    let height = y_minus_bg.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1551
1552    // Slit centre/width from the half-maximum crossings of (y - background).
1553    let threshold = 0.5 * height;
1554    let first = y_minus_bg.iter().position(|&v| v >= threshold)?;
1555    let last = y_minus_bg.iter().rposition(|&v| v >= threshold)?;
1556    let position = (x[first] + x[last]) / 2.0;
1557    let fwhm = x[last] - x[first];
1558
1559    // Beam sharpness: average of the edge FWHMs, clamped to silx's bounds.
1560    let mut beamfwhm = 0.5 * (fwhm_up + fwhm_down);
1561    beamfwhm = beamfwhm.min(edge_distance / 10.0);
1562    let xmin = x.iter().copied().fold(f64::INFINITY, f64::min);
1563    let xmax = x.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1564    beamfwhm = beamfwhm.max((xmax - xmin) * 3.0 / x.len() as f64);
1565
1566    Some(vec![height, position, fwhm, beamfwhm, bg])
1567}
1568
1569// ---------------------------------------------------------------------------
1570// Iterative fit models exposed through the FitFunction trait, and fit range.
1571// ---------------------------------------------------------------------------
1572
1573/// Which fit model (peak, step, or slit) an [`IterativeFit`] fits.
1574#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1575pub enum PeakModel {
1576    /// Gaussian, height parameterisation: `[height, centroid, fwhm, bg]`.
1577    Gaussian,
1578    /// Gaussian, area parameterisation: `[area, centroid, fwhm, bg]`.
1579    GaussianArea,
1580    /// Lorentzian, height parameterisation: `[height, centroid, fwhm, bg]`.
1581    Lorentzian,
1582    /// Pseudo-Voigt: `[height, centroid, fwhm, eta, bg]`.
1583    PseudoVoigt,
1584    /// Step down (descending erf edge): `[height, centroid, fwhm, bg]`.
1585    StepDown,
1586    /// Step up (ascending erf edge): `[height, centroid, fwhm, bg]`.
1587    StepUp,
1588    /// Slit (rising then falling edges): `[height, position, fwhm, beamfwhm, bg]`.
1589    Slit,
1590    /// Arctan step up: `[height, position, width, bg]`.
1591    AtanStepUp,
1592}
1593
1594impl PeakModel {
1595    /// Display name for this model.
1596    pub fn name(self) -> &'static str {
1597        match self {
1598            PeakModel::Gaussian => "Gaussian",
1599            PeakModel::GaussianArea => "Gaussian (Area)",
1600            PeakModel::Lorentzian => "Lorentzian",
1601            PeakModel::PseudoVoigt => "Pseudo-Voigt",
1602            PeakModel::StepDown => "Step Down",
1603            PeakModel::StepUp => "Step Up",
1604            PeakModel::Slit => "Slit",
1605            PeakModel::AtanStepUp => "Arctan Step Up",
1606        }
1607    }
1608
1609    /// Parameter names for this model, in parameter-vector order.
1610    pub fn param_names(self) -> Vec<String> {
1611        let owned = |s: &str| s.to_string();
1612        match self {
1613            PeakModel::Gaussian => vec![
1614                owned("Height"),
1615                owned("Center"),
1616                owned("FWHM"),
1617                owned("Background"),
1618            ],
1619            PeakModel::GaussianArea => vec![
1620                owned("Area"),
1621                owned("Center"),
1622                owned("FWHM"),
1623                owned("Background"),
1624            ],
1625            PeakModel::Lorentzian => vec![
1626                owned("Height"),
1627                owned("Center"),
1628                owned("FWHM"),
1629                owned("Background"),
1630            ],
1631            PeakModel::PseudoVoigt => vec![
1632                owned("Height"),
1633                owned("Center"),
1634                owned("FWHM"),
1635                owned("Eta"),
1636                owned("Background"),
1637            ],
1638            PeakModel::StepDown | PeakModel::StepUp => vec![
1639                owned("Height"),
1640                owned("Center"),
1641                owned("FWHM"),
1642                owned("Background"),
1643            ],
1644            PeakModel::Slit => vec![
1645                owned("Height"),
1646                owned("Center"),
1647                owned("FWHM"),
1648                owned("BeamFWHM"),
1649                owned("Background"),
1650            ],
1651            PeakModel::AtanStepUp => vec![
1652                owned("Height"),
1653                owned("Center"),
1654                owned("Width"),
1655                owned("Background"),
1656            ],
1657        }
1658    }
1659
1660    /// Evaluate this model over `x` with the given parameter vector.
1661    pub fn eval(self, x: &[f64], params: &[f64]) -> Vec<f64> {
1662        match self {
1663            PeakModel::Gaussian => gaussian_model(x, params),
1664            PeakModel::GaussianArea => gaussian_area_model(x, params),
1665            PeakModel::Lorentzian => lorentzian_model(x, params),
1666            PeakModel::PseudoVoigt => pseudo_voigt_model(x, params),
1667            PeakModel::StepDown => stepdown_model(x, params),
1668            PeakModel::StepUp => stepup_model(x, params),
1669            PeakModel::Slit => slit_model(x, params),
1670            PeakModel::AtanStepUp => atan_stepup_model(x, params),
1671        }
1672    }
1673
1674    /// Estimate an initial parameter vector for this model from the data.
1675    pub fn estimate(self, x: &[f64], y: &[f64]) -> Option<Vec<f64>> {
1676        match self {
1677            PeakModel::Gaussian => estimate_gaussian(x, y),
1678            PeakModel::GaussianArea => estimate_gaussian_area(x, y),
1679            PeakModel::Lorentzian => estimate_lorentzian(x, y),
1680            PeakModel::PseudoVoigt => estimate_pseudo_voigt(x, y),
1681            PeakModel::StepDown => estimate_stepdown(x, y),
1682            PeakModel::StepUp => estimate_stepup(x, y),
1683            PeakModel::Slit => estimate_slit(x, y),
1684            PeakModel::AtanStepUp => estimate_atan_stepup(x, y),
1685        }
1686    }
1687}
1688
1689/// Outcome of an iterative peak fit: the [`FitResult`] plus the solver
1690/// diagnostics needed for a results table (errors + reduced chi-square).
1691#[derive(Debug, Clone)]
1692pub struct IterativeFitResult {
1693    /// The fitted curve and parameters (compatible with the simple fitters).
1694    pub fit: FitResult,
1695    /// Full solver output (covariance, chi-square, iteration counts).
1696    pub solver: LeastSqResult,
1697}
1698
1699impl IterativeFitResult {
1700    /// Per-parameter standard errors (`sqrt(diag(covariance))`).
1701    pub fn std_errors(&self) -> Vec<f64> {
1702        self.solver.std_errors()
1703    }
1704
1705    /// Reduced chi-square, if degrees of freedom were positive.
1706    pub fn reduced_chisq(&self) -> Option<f64> {
1707        self.solver.reduced_chisq
1708    }
1709}
1710
1711/// An iterative (Levenberg-Marquardt) peak fitter for one [`PeakModel`].
1712///
1713/// Estimates initial parameters with [`PeakModel::estimate`], then refines them
1714/// with [`leastsq`]. The [`FitFunction`] impl returns the refined [`FitResult`];
1715/// use [`IterativeFit::fit_full`] to also obtain the covariance / chi-square.
1716pub struct IterativeFit {
1717    /// The peak model fitted by this instance.
1718    pub model: PeakModel,
1719    /// Maximum LM iterations (defaults to [`DEFAULT_MAX_ITER`]).
1720    pub max_iter: usize,
1721    /// Relative chi-square stop threshold (defaults to [`DEFAULT_DELTACHI`]).
1722    pub deltachi: f64,
1723}
1724
1725impl IterativeFit {
1726    /// Create an iterative fitter for `model` with silx default iteration
1727    /// controls.
1728    pub fn new(model: PeakModel) -> Self {
1729        Self {
1730            model,
1731            max_iter: DEFAULT_MAX_ITER,
1732            deltachi: DEFAULT_DELTACHI,
1733        }
1734    }
1735
1736    /// Fit and return the full solver diagnostics (covariance, chi-square).
1737    pub fn fit_full(&self, x: &[f64], y: &[f64]) -> Option<IterativeFitResult> {
1738        let p0 = self.model.estimate(x, y)?;
1739        let model = self.model;
1740        let solver = leastsq(
1741            |xx, pp| model.eval(xx, pp),
1742            x,
1743            y,
1744            &p0,
1745            None,
1746            self.max_iter,
1747            self.deltachi,
1748        )
1749        .ok()?;
1750        let y_fit = self.model.eval(x, &solver.parameters);
1751        let fit = FitResult {
1752            y_fit,
1753            parameters: solver.parameters.clone(),
1754            param_names: self.model.param_names(),
1755        };
1756        Some(IterativeFitResult { fit, solver })
1757    }
1758}
1759
1760impl FitFunction for IterativeFit {
1761    fn name(&self) -> &str {
1762        self.model.name()
1763    }
1764
1765    fn fit(&self, x: &[f64], y: &[f64]) -> Option<FitResult> {
1766        self.fit_full(x, y).map(|r| r.fit)
1767    }
1768}
1769
1770/// Fit `model` to only the data points whose `x` falls within `[xmin, xmax]`
1771/// (inclusive), mirroring silx `FitWidget` xmin/xmax range restriction.
1772///
1773/// Points outside the range are dropped before fitting, so they cannot
1774/// influence the fitted parameters. `xmin`/`xmax` may be given in any order.
1775/// Returns `None` if fewer than 3 points remain in range.
1776pub fn fit_in_range(
1777    xs: &[f64],
1778    ys: &[f64],
1779    xmin: f64,
1780    xmax: f64,
1781    model: &IterativeFit,
1782) -> Option<IterativeFitResult> {
1783    if xs.len() != ys.len() {
1784        return None;
1785    }
1786    let (lo, hi) = if xmin <= xmax {
1787        (xmin, xmax)
1788    } else {
1789        (xmax, xmin)
1790    };
1791    let mut xr = Vec::new();
1792    let mut yr = Vec::new();
1793    for (&xi, &yi) in xs.iter().zip(ys.iter()) {
1794        if xi >= lo && xi <= hi {
1795            xr.push(xi);
1796            yr.push(yi);
1797        }
1798    }
1799    if xr.len() < 3 {
1800        return None;
1801    }
1802    model.fit_full(&xr, &yr)
1803}
1804
1805// ---------------------------------------------------------------------------
1806// Multi-peak (simultaneous) Gaussian fitting.
1807//
1808// Ports silx `fittheories.estimate_height_position_fwhm` + `functions.sum_gauss`
1809// (C `sum_gauss`): locate N peaks with `peak_search`, seed one (height, centre,
1810// FWHM) triple per peak, and fit them all at once with the constrained solver.
1811// This composes `peak_search` and `leastsq_constrained` into the multi-peak fit
1812// that `IterativeFit` (single peak) does not provide.
1813// ---------------------------------------------------------------------------
1814
1815/// Sum of `N` Gaussians (silx `functions.sum_gauss` / C `sum_gauss`).
1816///
1817/// `params` is a flat vector of `(height, centroid, fwhm)` triples — one per
1818/// peak, with **no** background term. `y(x) = Σ_k height_k · exp(-0.5·((x −
1819/// centroid_k)/sigma_k)²)`, `sigma = fwhm / (2·√(2·ln2))`, with the C far-tail
1820/// guard skipping terms where `(x − centroid)/sigma > 20`. A trailing partial
1821/// triple (length not a multiple of 3) is ignored, as silx requires a multiple
1822/// of 3.
1823pub fn multi_gaussian_model(x: &[f64], params: &[f64]) -> Vec<f64> {
1824    let inv = 1.0 / fwhm_to_sigma_factor();
1825    let mut y = vec![0.0_f64; x.len()];
1826    for triple in params.chunks_exact(3) {
1827        let (height, centroid, fwhm) = (triple[0], triple[1], triple[2]);
1828        let sigma = fwhm * inv;
1829        if sigma == 0.0 {
1830            continue;
1831        }
1832        for (yi, &xi) in y.iter_mut().zip(x.iter()) {
1833            let dhelp = (xi - centroid) / sigma;
1834            if dhelp <= 20.0 {
1835                *yi += height * (-0.5 * dhelp * dhelp).exp();
1836            }
1837        }
1838    }
1839    y
1840}
1841
1842/// Estimate initial parameters and fit constraints for a multi-peak Gaussian fit
1843/// (silx `estimate_height_position_fwhm`, default config).
1844///
1845/// Locates peaks with [`peak_search`](crate::core::peaks::peak_search)
1846/// (`search_fwhm` floored at 3, `sensitivity` floored at 1; falls back to the
1847/// global maximum when none are found, silx `ForcePeakPresence`). Seeds each
1848/// peak as `(y[peak], x[peak], 5·|xspan|/n)`, refines the seeds with a quick
1849/// 4-iteration constrained fit (height/FWHM positive, centre quoted to ±½ of the
1850/// `search_fwhm`-sample x-width), and returns the refined seeds together with the
1851/// final per-parameter constraints (height & FWHM `Positive`, centre `Free` —
1852/// silx default `PositiveHeightAreaFlag`/`PositiveFwhmFlag` on, `QuotedPositionFlag`
1853/// off). Background removal (silx `StripBackgroundFlag`, off by default) is left
1854/// to the caller via [`crate::core::background`]. Returns `None` for empty or
1855/// length-mismatched input, or when no peak can be seeded.
1856pub fn estimate_multi_gaussian(
1857    x: &[f64],
1858    y: &[f64],
1859    search_fwhm: f64,
1860    sensitivity: f64,
1861) -> Option<(Vec<f64>, Vec<Constraint>)> {
1862    let npoints = y.len();
1863    if npoints == 0 || x.len() != npoints {
1864        return None;
1865    }
1866    let search_fwhm = search_fwhm.max(3.0);
1867    let search_sens = sensitivity.max(1.0);
1868
1869    let found = crate::core::peaks::peak_search(y, search_fwhm, search_sens);
1870    let peaks: Vec<usize> = if found.is_empty() {
1871        // silx ForcePeakPresence: use the (first) global maximum.
1872        let maxv = y.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1873        match y.iter().position(|&v| v == maxv) {
1874            Some(p) => vec![p],
1875            None => return None,
1876        }
1877    } else {
1878        found.iter().map(|p| p.index).collect()
1879    };
1880    if peaks.is_empty() {
1881        return None;
1882    }
1883
1884    // silx seeds FWHM as 5 sampling intervals (in x units).
1885    let sig = 5.0 * (x[npoints - 1] - x[0]).abs() / npoints as f64;
1886    let mut param: Vec<f64> = Vec::with_capacity(peaks.len() * 3);
1887    let mut index_largest = 0usize;
1888    let mut height_largest = f64::NEG_INFINITY;
1889    for (k, &pi) in peaks.iter().enumerate() {
1890        let height = y[pi];
1891        // silx zeroes a near-zero position only for the first peak.
1892        let pos = if k == 0 && x[pi].abs() < 1.0e-16 {
1893            0.0
1894        } else {
1895            x[pi]
1896        };
1897        param.push(height);
1898        param.push(pos);
1899        param.push(sig);
1900        if height > height_largest {
1901            height_largest = height;
1902            index_largest = k;
1903        }
1904    }
1905    let _ = index_largest; // used by silx SameFwhmFlag (off by default).
1906
1907    // Preliminary constraints for the quick refine: height & FWHM positive,
1908    // centre quoted around its seed.
1909    let sf = search_fwhm as usize;
1910    let (fwhmx, use_fwhmx) = if x.len() > sf {
1911        ((x[sf] - x[0]).abs(), true)
1912    } else {
1913        (0.0, false)
1914    };
1915    let xmin = x.iter().copied().fold(f64::INFINITY, f64::min);
1916    let xmax = x.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1917    let mut prelim: Vec<Constraint> = Vec::with_capacity(param.len());
1918    for k in 0..peaks.len() {
1919        let pos = param[3 * k + 1];
1920        prelim.push(Constraint::Positive);
1921        if use_fwhmx && fwhmx > 0.0 {
1922            prelim.push(Constraint::Quoted {
1923                min: pos - 0.5 * fwhmx,
1924                max: pos + 0.5 * fwhmx,
1925            });
1926        } else if xmax > xmin {
1927            prelim.push(Constraint::Quoted {
1928                min: xmin,
1929                max: xmax,
1930            });
1931        } else {
1932            prelim.push(Constraint::Free);
1933        }
1934        prelim.push(Constraint::Positive);
1935    }
1936
1937    // Quick 4-iteration refine (silx max_iter=4); fall back to the raw seeds.
1938    let fittedpar = leastsq_constrained(
1939        multi_gaussian_model,
1940        x,
1941        y,
1942        &param,
1943        &prelim,
1944        None,
1945        4,
1946        DEFAULT_DELTACHI,
1947    )
1948    .map(|r| r.parameters)
1949    .unwrap_or(param);
1950
1951    // Final constraints (default config): height & FWHM positive, centre free.
1952    let mut cons: Vec<Constraint> = Vec::with_capacity(fittedpar.len());
1953    for _ in 0..peaks.len() {
1954        cons.push(Constraint::Positive);
1955        cons.push(Constraint::Free);
1956        cons.push(Constraint::Positive);
1957    }
1958
1959    Some((fittedpar, cons))
1960}
1961
1962/// Fit a sum of Gaussians to `(x, y)`, discovering the peaks automatically
1963/// (silx `FitManager` multi-peak Gaussian fit).
1964///
1965/// Seeds the peaks with [`estimate_multi_gaussian`] then runs the full
1966/// constrained Levenberg-Marquardt fit ([`leastsq_constrained`]) over all peaks
1967/// simultaneously. The returned [`LeastSqResult::parameters`] is the flat
1968/// `(height, centroid, fwhm)` triple vector accepted by [`multi_gaussian_model`].
1969/// Returns `None` when no peak can be seeded or the solver fails. Background is
1970/// the caller's responsibility (see [`crate::core::background`]).
1971pub fn fit_multi_gaussian(
1972    x: &[f64],
1973    y: &[f64],
1974    search_fwhm: f64,
1975    sensitivity: f64,
1976    max_iter: usize,
1977    deltachi: f64,
1978) -> Option<LeastSqResult> {
1979    let (seeds, cons) = estimate_multi_gaussian(x, y, search_fwhm, sensitivity)?;
1980    leastsq_constrained(
1981        multi_gaussian_model,
1982        x,
1983        y,
1984        &seeds,
1985        &cons,
1986        None,
1987        max_iter,
1988        deltachi,
1989    )
1990    .ok()
1991}
1992
1993/// Run the multi-peak Gaussian fit ([`fit_multi_gaussian`]) and package it as an
1994/// [`IterativeFitResult`] for display.
1995///
1996/// The fitted curve is [`multi_gaussian_model`] evaluated over the located
1997/// peaks; the parameter vector is the flat `(height, centre, fwhm)` triples;
1998/// the per-peak names are `Height i` / `Center i` / `FWHM i` (1-based); and the
1999/// solver covariance / chi-square come straight from the constrained solve.
2000/// Returns `None` when no peak is seeded or the solver fails.
2001pub fn fit_multi_gaussian_full(
2002    x: &[f64],
2003    y: &[f64],
2004    search_fwhm: f64,
2005    sensitivity: f64,
2006    max_iter: usize,
2007    deltachi: f64,
2008) -> Option<IterativeFitResult> {
2009    let solver = fit_multi_gaussian(x, y, search_fwhm, sensitivity, max_iter, deltachi)?;
2010    let y_fit = multi_gaussian_model(x, &solver.parameters);
2011    let mut param_names = Vec::with_capacity(solver.parameters.len());
2012    for peak in 0..solver.parameters.len() / 3 {
2013        let i = peak + 1;
2014        param_names.push(format!("Height {i}"));
2015        param_names.push(format!("Center {i}"));
2016        param_names.push(format!("FWHM {i}"));
2017    }
2018    let fit = FitResult {
2019        y_fit,
2020        parameters: solver.parameters.clone(),
2021        param_names,
2022    };
2023    Some(IterativeFitResult { fit, solver })
2024}
2025
2026/// Fit a single [`PeakModel`] from explicit initial parameters `p0` under
2027/// per-parameter [`Constraint`]s (silx FitWidget parameter table → editable
2028/// values + constraint codes feeding `leastsq_constrained`).
2029///
2030/// `p0` and `constraints` must each have exactly one entry per model parameter
2031/// (the order of [`PeakModel::param_names`]). Returns `None` on a length
2032/// mismatch or solver failure. This is the single owner of the
2033/// constrained-fit-from-`p0` path; [`fit_peak_constrained`] estimates `p0` and
2034/// delegates here.
2035pub fn fit_peak_from(
2036    model: PeakModel,
2037    x: &[f64],
2038    y: &[f64],
2039    p0: &[f64],
2040    constraints: &[Constraint],
2041    max_iter: usize,
2042    deltachi: f64,
2043) -> Option<IterativeFitResult> {
2044    if constraints.len() != p0.len() {
2045        return None;
2046    }
2047    let solver = leastsq_constrained(
2048        |xx, pp| model.eval(xx, pp),
2049        x,
2050        y,
2051        p0,
2052        constraints,
2053        None,
2054        max_iter,
2055        deltachi,
2056    )
2057    .ok()?;
2058    let y_fit = model.eval(x, &solver.parameters);
2059    let fit = FitResult {
2060        y_fit,
2061        parameters: solver.parameters.clone(),
2062        param_names: model.param_names(),
2063    };
2064    Some(IterativeFitResult { fit, solver })
2065}
2066
2067/// Fit a single [`PeakModel`] under per-parameter [`Constraint`]s, with the
2068/// initial parameters estimated from the data ([`PeakModel::estimate`]).
2069///
2070/// `constraints` must have exactly one entry per model parameter. Returns `None`
2071/// on estimate/solver failure or a constraint-count mismatch. Delegates to
2072/// [`fit_peak_from`] once the estimate is in hand.
2073pub fn fit_peak_constrained(
2074    model: PeakModel,
2075    x: &[f64],
2076    y: &[f64],
2077    constraints: &[Constraint],
2078    max_iter: usize,
2079    deltachi: f64,
2080) -> Option<IterativeFitResult> {
2081    let p0 = model.estimate(x, y)?;
2082    fit_peak_from(model, x, y, &p0, constraints, max_iter, deltachi)
2083}
2084
2085/// Outcome of [`fit_peak_with_background`]: the peak fit on the
2086/// background-subtracted residual, the estimated background curve, and the
2087/// total displayed curve.
2088#[derive(Debug, Clone)]
2089pub struct BackgroundPeakFit {
2090    /// The peak fit on the background-subtracted residual. `peak.fit.parameters`
2091    /// are in the [`PeakModel`]'s own parameterisation; per-parameter errors come
2092    /// from [`IterativeFitResult::std_errors`].
2093    pub peak: IterativeFitResult,
2094    /// The background curve estimated from the data and sampled at the fit `x`
2095    /// (held fixed during the peak fit), length `x.len()`.
2096    pub background: Vec<f64>,
2097    /// The total curve to display, `background[i] + peak.fit.y_fit[i]`.
2098    pub total: Vec<f64>,
2099}
2100
2101/// Fit `model` on top of an estimated `background`, mirroring silx `FitManager`'s
2102/// background-then-peak workflow.
2103///
2104/// The background is estimated from the data with [`Background`] (silx
2105/// `estimate_*`: the strip/snip filters, or a polynomial least-squares-fitted to
2106/// the strip background — silx `EstimatePolyOnStrip = True`), subtracted from
2107/// `y`, and the [`PeakModel`] is fitted to the residual. The background is then
2108/// added back to produce [`BackgroundPeakFit::total`].
2109///
2110/// Returns `None` on length mismatch, empty input, or when the peak fit fails.
2111///
2112/// # Deviation from silx
2113///
2114/// silx's background theories are `is_background=True` `FitTheory`s whose
2115/// parameters are concatenated with the peak parameters into ONE `leastsq`, so
2116/// the analytic-background coefficients (Constant / Linear / Polynomial) are
2117/// refined *simultaneously* with the peak. Here the background is estimated once
2118/// and held fixed while the peak is fitted on the residual. A simultaneous
2119/// refinement is blocked by siplot's single-peak [`PeakModel`]s baking in their
2120/// own trailing constant-background parameter: a free analytic-background
2121/// constant would be collinear with it (singular covariance). Removing that
2122/// baked-in constant is a [`PeakModel`] redesign tracked separately.
2123///
2124/// [`Background`]: crate::core::background::Background
2125pub fn fit_peak_with_background(
2126    model: PeakModel,
2127    background: crate::core::background::Background,
2128    x: &[f64],
2129    y: &[f64],
2130    max_iter: usize,
2131    deltachi: f64,
2132) -> Option<BackgroundPeakFit> {
2133    if x.is_empty() || x.len() != y.len() {
2134        return None;
2135    }
2136    let bg = background.compute(x, y);
2137    let residual: Vec<f64> = y.iter().zip(&bg).map(|(&yi, &bi)| yi - bi).collect();
2138    let fitter = IterativeFit {
2139        model,
2140        max_iter,
2141        deltachi,
2142    };
2143    let peak = fitter.fit_full(x, &residual)?;
2144    let total: Vec<f64> = bg
2145        .iter()
2146        .zip(&peak.fit.y_fit)
2147        .map(|(&bi, &fi)| bi + fi)
2148        .collect();
2149    Some(BackgroundPeakFit {
2150        peak,
2151        background: bg,
2152        total,
2153    })
2154}
2155
2156#[cfg(test)]
2157mod tests {
2158    use super::*;
2159    use crate::core::peaks::DEFAULT_PEAK_SENSITIVITY;
2160
2161    /// Synthetic noiseless Gaussian sampled on a grid.
2162    fn synth_gaussian(xs: &[f64], height: f64, center: f64, fwhm: f64, bg: f64) -> Vec<f64> {
2163        gaussian_model(xs, &[height, center, fwhm, bg])
2164    }
2165
2166    fn linspace(a: f64, b: f64, n: usize) -> Vec<f64> {
2167        (0..n)
2168            .map(|i| a + (b - a) * (i as f64) / ((n - 1) as f64))
2169            .collect()
2170    }
2171
2172    #[test]
2173    fn invert_identity() {
2174        let id = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
2175        let inv = invert_matrix(&id).unwrap();
2176        assert_eq!(inv, id);
2177    }
2178
2179    #[test]
2180    fn invert_known_2x2() {
2181        // [[4,7],[2,6]] inverse = [[0.6,-0.7],[-0.2,0.4]]
2182        let m = vec![vec![4.0, 7.0], vec![2.0, 6.0]];
2183        let inv = invert_matrix(&m).unwrap();
2184        let expected = [[0.6, -0.7], [-0.2, 0.4]];
2185        for i in 0..2 {
2186            for j in 0..2 {
2187                assert!((inv[i][j] - expected[i][j]).abs() < 1e-12);
2188            }
2189        }
2190    }
2191
2192    #[test]
2193    fn invert_singular_returns_none() {
2194        let m = vec![vec![1.0, 2.0], vec![2.0, 4.0]];
2195        assert!(invert_matrix(&m).is_none());
2196    }
2197
2198    #[test]
2199    fn leastsq_recovers_noiseless_line_exactly() {
2200        // Model: y = a*x + b, params [a, b]. Noiseless data with a=2.5, b=-1.0.
2201        let xs = linspace(-5.0, 5.0, 21);
2202        let (a_true, b_true) = (2.5, -1.0);
2203        let ys: Vec<f64> = xs.iter().map(|&x| a_true * x + b_true).collect();
2204        let model = |x: &[f64], p: &[f64]| x.iter().map(|&xi| p[0] * xi + p[1]).collect::<Vec<_>>();
2205        let res = leastsq(
2206            model,
2207            &xs,
2208            &ys,
2209            &[0.0, 0.0],
2210            None,
2211            DEFAULT_MAX_ITER,
2212            DEFAULT_DELTACHI,
2213        )
2214        .unwrap();
2215        assert!(
2216            (res.parameters[0] - a_true).abs() < 1e-6,
2217            "slope {} vs {}",
2218            res.parameters[0],
2219            a_true
2220        );
2221        assert!(
2222            (res.parameters[1] - b_true).abs() < 1e-6,
2223            "intercept {} vs {}",
2224            res.parameters[1],
2225            b_true
2226        );
2227        // Noiseless → chisq essentially zero.
2228        assert!(res.chisq < 1e-12, "chisq {}", res.chisq);
2229    }
2230
2231    #[test]
2232    fn leastsq_converges_on_noisy_gaussian() {
2233        // Synthetic gaussian + small deterministic "noise" so the test is
2234        // reproducible. height=10, center=2, fwhm=1.5, bg=1.
2235        let xs = linspace(-3.0, 7.0, 101);
2236        let clean = synth_gaussian(&xs, 10.0, 2.0, 1.5, 1.0);
2237        // Deterministic pseudo-noise: small sinusoidal perturbation.
2238        let ys: Vec<f64> = clean
2239            .iter()
2240            .enumerate()
2241            .map(|(i, &c)| c + 0.05 * ((i as f64) * 0.7).sin())
2242            .collect();
2243        let fit = IterativeFit::new(PeakModel::Gaussian)
2244            .fit_full(&xs, &ys)
2245            .expect("fit should succeed");
2246        let p = &fit.fit.parameters;
2247        assert!((p[0] - 10.0).abs() < 0.2, "height {}", p[0]);
2248        assert!((p[1] - 2.0).abs() < 0.05, "center {}", p[1]);
2249        assert!((p[2] - 1.5).abs() < 0.1, "fwhm {}", p[2]);
2250        assert!((p[3] - 1.0).abs() < 0.1, "bg {}", p[3]);
2251        // Reduced chi-square (sigma=1) is on the order of the perturbation
2252        // variance, not enormous.
2253        let rc = fit.reduced_chisq().unwrap();
2254        assert!(rc < 0.01, "reduced chisq {}", rc);
2255    }
2256
2257    #[test]
2258    fn gaussian_model_recovers_own_peak() {
2259        let xs = linspace(0.0, 20.0, 201);
2260        let ys = synth_gaussian(&xs, 5.0, 8.0, 2.0, 0.5);
2261        let fit = IterativeFit::new(PeakModel::Gaussian)
2262            .fit_full(&xs, &ys)
2263            .unwrap();
2264        let p = &fit.fit.parameters;
2265        assert!((p[0] - 5.0).abs() < 1e-3, "height {}", p[0]);
2266        assert!((p[1] - 8.0).abs() < 1e-3, "center {}", p[1]);
2267        assert!((p[2] - 2.0).abs() < 1e-3, "fwhm {}", p[2]);
2268        assert!((p[3] - 0.5).abs() < 1e-3, "bg {}", p[3]);
2269        // Noiseless fit → reduced chisq near 0.
2270        assert!(fit.reduced_chisq().unwrap() < 1e-6);
2271    }
2272
2273    #[test]
2274    fn gaussian_area_model_recovers_own_peak() {
2275        // Build data from the area model with a known area.
2276        let xs = linspace(0.0, 20.0, 201);
2277        let area = 12.0;
2278        let ys = gaussian_area_model(&xs, &[area, 9.0, 2.5, 0.2]);
2279        let fit = IterativeFit::new(PeakModel::GaussianArea)
2280            .fit_full(&xs, &ys)
2281            .unwrap();
2282        let p = &fit.fit.parameters;
2283        assert!((p[0] - area).abs() < 1e-2, "area {}", p[0]);
2284        assert!((p[1] - 9.0).abs() < 1e-3, "center {}", p[1]);
2285        assert!((p[2] - 2.5).abs() < 1e-3, "fwhm {}", p[2]);
2286        assert!((p[3] - 0.2).abs() < 1e-3, "bg {}", p[3]);
2287        assert!(fit.reduced_chisq().unwrap() < 1e-6);
2288    }
2289
2290    #[test]
2291    fn lorentzian_model_recovers_own_peak() {
2292        let xs = linspace(0.0, 20.0, 201);
2293        let ys = lorentzian_model(&xs, &[7.0, 11.0, 3.0, 1.0]);
2294        let fit = IterativeFit::new(PeakModel::Lorentzian)
2295            .fit_full(&xs, &ys)
2296            .unwrap();
2297        let p = &fit.fit.parameters;
2298        assert!((p[0] - 7.0).abs() < 1e-2, "height {}", p[0]);
2299        assert!((p[1] - 11.0).abs() < 1e-3, "center {}", p[1]);
2300        assert!((p[2] - 3.0).abs() < 1e-2, "fwhm {}", p[2]);
2301        assert!((p[3] - 1.0).abs() < 1e-2, "bg {}", p[3]);
2302        assert!(fit.reduced_chisq().unwrap() < 1e-6);
2303    }
2304
2305    #[test]
2306    fn pseudo_voigt_model_recovers_own_peak() {
2307        let xs = linspace(0.0, 20.0, 301);
2308        let ys = pseudo_voigt_model(&xs, &[6.0, 10.0, 2.0, 0.4, 0.5]);
2309        let fit = IterativeFit::new(PeakModel::PseudoVoigt)
2310            .fit_full(&xs, &ys)
2311            .unwrap();
2312        let p = &fit.fit.parameters;
2313        assert!((p[0] - 6.0).abs() < 5e-2, "height {}", p[0]);
2314        assert!((p[1] - 10.0).abs() < 1e-2, "center {}", p[1]);
2315        assert!((p[2] - 2.0).abs() < 5e-2, "fwhm {}", p[2]);
2316        assert!((p[3] - 0.4).abs() < 5e-2, "eta {}", p[3]);
2317        assert!((p[4] - 0.5).abs() < 5e-2, "bg {}", p[4]);
2318        assert!(fit.reduced_chisq().unwrap() < 1e-4);
2319    }
2320
2321    #[test]
2322    fn pseudo_voigt_eta_limits_match_gauss_and_lorentz() {
2323        // eta=0 → pure Gaussian; eta=1 → pure Lorentzian (same height/center/fwhm).
2324        let xs = linspace(0.0, 10.0, 51);
2325        let g = gaussian_model(&xs, &[3.0, 5.0, 2.0, 0.0]);
2326        let pv_g = pseudo_voigt_model(&xs, &[3.0, 5.0, 2.0, 0.0, 0.0]);
2327        for (a, b) in g.iter().zip(pv_g.iter()) {
2328            assert!((a - b).abs() < 1e-12);
2329        }
2330        let l = lorentzian_model(&xs, &[3.0, 5.0, 2.0, 0.0]);
2331        let pv_l = pseudo_voigt_model(&xs, &[3.0, 5.0, 2.0, 1.0, 0.0]);
2332        for (a, b) in l.iter().zip(pv_l.iter()) {
2333            assert!((a - b).abs() < 1e-12);
2334        }
2335    }
2336
2337    #[test]
2338    fn fit_in_range_ignores_outside_points() {
2339        // A clean gaussian inside [4, 12]; outside the range we plant a wildly
2340        // different curve. If out-of-range points were used, the fit would be
2341        // pulled away from the true peak.
2342        let xs = linspace(0.0, 20.0, 201);
2343        let in_range: Vec<f64> = xs
2344            .iter()
2345            .map(|&x| {
2346                if (4.0..=12.0).contains(&x) {
2347                    // true gaussian
2348                    let sigma = 2.0 / fwhm_to_sigma_factor();
2349                    let d = (x - 8.0) / sigma;
2350                    5.0 * (-0.5 * d * d).exp() + 0.5
2351                } else {
2352                    // garbage outside the range
2353                    100.0 + 50.0 * x
2354                }
2355            })
2356            .collect();
2357        let fitter = IterativeFit::new(PeakModel::Gaussian);
2358        let res = fit_in_range(&xs, &in_range, 4.0, 12.0, &fitter).unwrap();
2359        let p = &res.fit.parameters;
2360        assert!((p[1] - 8.0).abs() < 0.05, "center pulled to {}", p[1]);
2361        assert!((p[2] - 2.0).abs() < 0.1, "fwhm {}", p[2]);
2362        assert!((p[0] - 5.0).abs() < 0.2, "height {}", p[0]);
2363    }
2364
2365    #[test]
2366    fn fit_in_range_reversed_bounds_equivalent() {
2367        let xs = linspace(0.0, 20.0, 201);
2368        let ys = synth_gaussian(&xs, 4.0, 10.0, 2.0, 0.3);
2369        let fitter = IterativeFit::new(PeakModel::Gaussian);
2370        let a = fit_in_range(&xs, &ys, 6.0, 14.0, &fitter).unwrap();
2371        let b = fit_in_range(&xs, &ys, 14.0, 6.0, &fitter).unwrap();
2372        for (pa, pb) in a.fit.parameters.iter().zip(b.fit.parameters.iter()) {
2373            assert!((pa - pb).abs() < 1e-12);
2374        }
2375    }
2376
2377    #[test]
2378    fn std_errors_from_covariance_diagonal() {
2379        // Construct a LeastSqResult with a known covariance and verify the
2380        // error extraction (sqrt of the diagonal).
2381        let res = LeastSqResult {
2382            parameters: vec![1.0, 2.0, 3.0],
2383            covariance: vec![
2384                vec![4.0, 0.1, 0.0],
2385                vec![0.1, 9.0, 0.2],
2386                vec![0.0, 0.2, 16.0],
2387            ],
2388            uncertainties: vec![2.0, 3.0, 4.0],
2389            chisq: 0.0,
2390            reduced_chisq: Some(0.0),
2391            niter: 1,
2392            nfev: 1,
2393        };
2394        let errs = res.std_errors();
2395        assert!((errs[0] - 2.0).abs() < 1e-12);
2396        assert!((errs[1] - 3.0).abs() < 1e-12);
2397        assert!((errs[2] - 4.0).abs() < 1e-12);
2398    }
2399
2400    #[test]
2401    fn std_errors_guard_negative_diagonal() {
2402        // A tiny negative diagonal (round-off) must not produce NaN; abs first.
2403        let res = LeastSqResult {
2404            parameters: vec![1.0],
2405            covariance: vec![vec![-1e-15]],
2406            uncertainties: vec![0.0],
2407            chisq: 0.0,
2408            reduced_chisq: None,
2409            niter: 0,
2410            nfev: 0,
2411        };
2412        let e = res.std_errors();
2413        assert!(e[0].is_finite() && e[0] >= 0.0);
2414    }
2415
2416    #[test]
2417    fn leastsq_length_mismatch_errors() {
2418        let r = leastsq(
2419            |x: &[f64], _p: &[f64]| x.to_vec(),
2420            &[1.0, 2.0, 3.0],
2421            &[1.0, 2.0],
2422            &[0.0],
2423            None,
2424            10,
2425            DEFAULT_DELTACHI,
2426        );
2427        assert_eq!(r.unwrap_err(), FitError::LengthMismatch);
2428    }
2429
2430    #[test]
2431    fn leastsq_rejects_nonfinite() {
2432        let r = leastsq(
2433            |x: &[f64], p: &[f64]| x.iter().map(|&xi| p[0] * xi).collect::<Vec<_>>(),
2434            &[1.0, f64::NAN, 3.0],
2435            &[1.0, 2.0, 3.0],
2436            &[1.0],
2437            None,
2438            10,
2439            DEFAULT_DELTACHI,
2440        );
2441        assert_eq!(r.unwrap_err(), FitError::NonFinite);
2442    }
2443
2444    #[test]
2445    fn estimate_seeds_are_close() {
2446        let xs = linspace(0.0, 20.0, 201);
2447        let ys = synth_gaussian(&xs, 5.0, 8.0, 2.0, 0.5);
2448        let (h, c, f, bg) = estimate_height_position_fwhm(&xs, &ys).unwrap();
2449        assert!((h - 5.0).abs() < 0.5, "height seed {}", h);
2450        assert!((c - 8.0).abs() < 0.2, "center seed {}", c);
2451        assert!((f - 2.0).abs() < 0.5, "fwhm seed {}", f);
2452        assert!((bg - 0.5).abs() < 0.1, "bg seed {}", bg);
2453    }
2454
2455    // --- Constrained leastsq (silx constraint codes) -----------------------
2456
2457    // Straight line y = p[0]*x + p[1].
2458    fn line(x: &[f64], p: &[f64]) -> Vec<f64> {
2459        x.iter().map(|&xi| p[0] * xi + p[1]).collect()
2460    }
2461    // Constant y = p[0].
2462    fn constant(x: &[f64], p: &[f64]) -> Vec<f64> {
2463        vec![p[0]; x.len()]
2464    }
2465
2466    #[test]
2467    fn constrained_all_free_matches_unconstrained() {
2468        let xs = linspace(-5.0, 5.0, 21);
2469        let ys: Vec<f64> = xs.iter().map(|&x| 2.5 * x - 1.0).collect();
2470        let free = leastsq_constrained(
2471            line,
2472            &xs,
2473            &ys,
2474            &[0.0, 0.0],
2475            &[Constraint::Free, Constraint::Free],
2476            None,
2477            DEFAULT_MAX_ITER,
2478            DEFAULT_DELTACHI,
2479        )
2480        .unwrap();
2481        let plain = leastsq(
2482            line,
2483            &xs,
2484            &ys,
2485            &[0.0, 0.0],
2486            None,
2487            DEFAULT_MAX_ITER,
2488            DEFAULT_DELTACHI,
2489        )
2490        .unwrap();
2491        assert!((free.parameters[0] - plain.parameters[0]).abs() < 1e-6);
2492        assert!((free.parameters[1] - plain.parameters[1]).abs() < 1e-6);
2493        assert!((free.parameters[0] - 2.5).abs() < 1e-6);
2494        assert!((free.parameters[1] + 1.0).abs() < 1e-6);
2495    }
2496
2497    #[test]
2498    fn constrained_fixed_holds_parameter() {
2499        let xs = linspace(-5.0, 5.0, 21);
2500        let ys: Vec<f64> = xs.iter().map(|&x| 2.5 * x - 1.0).collect();
2501        // b fixed at its true value -1.0; only a is fitted and must recover 2.5.
2502        let res = leastsq_constrained(
2503            line,
2504            &xs,
2505            &ys,
2506            &[0.0, -1.0],
2507            &[Constraint::Free, Constraint::Fixed],
2508            None,
2509            DEFAULT_MAX_ITER,
2510            DEFAULT_DELTACHI,
2511        )
2512        .unwrap();
2513        assert_eq!(res.parameters[1], -1.0, "fixed b must not move");
2514        assert!(
2515            (res.parameters[0] - 2.5).abs() < 1e-6,
2516            "a {}",
2517            res.parameters[0]
2518        );
2519    }
2520
2521    #[test]
2522    fn constrained_fixed_gets_full_uncertainty() {
2523        let xs = linspace(-5.0, 5.0, 21);
2524        let ys: Vec<f64> = xs.iter().map(|&x| 2.5 * x - 1.0).collect();
2525        let res = leastsq_constrained(
2526            line,
2527            &xs,
2528            &ys,
2529            &[0.0, -1.0],
2530            &[Constraint::Free, Constraint::Fixed],
2531            None,
2532            DEFAULT_MAX_ITER,
2533            DEFAULT_DELTACHI,
2534        )
2535        .unwrap();
2536        // silx: fixed parameter gets covariance diag = value^2 and uncertainty = value.
2537        assert_eq!(res.uncertainties[1], res.parameters[1]);
2538        assert!((res.covariance[1][1] - res.parameters[1] * res.parameters[1]).abs() < 1e-12);
2539    }
2540
2541    #[test]
2542    fn constrained_positive_enforces_and_recovers() {
2543        let xs = linspace(0.0, 10.0, 21);
2544        // Negative target: the abs reparametrisation keeps the parameter
2545        // non-negative, so it can never reach -3 and the residual stays large.
2546        let neg: Vec<f64> = vec![-3.0; xs.len()];
2547        let r_neg = leastsq_constrained(
2548            constant,
2549            &xs,
2550            &neg,
2551            &[1.0],
2552            &[Constraint::Positive],
2553            None,
2554            DEFAULT_MAX_ITER,
2555            DEFAULT_DELTACHI,
2556        )
2557        .unwrap();
2558        assert!(
2559            r_neg.parameters[0] >= 0.0,
2560            "positive violated: {}",
2561            r_neg.parameters[0]
2562        );
2563        assert!(
2564            r_neg.chisq > 1.0,
2565            "constraint should prevent fitting the negative target, chisq {}",
2566            r_neg.chisq
2567        );
2568        // Positive target: behaves like a normal fit, recovering 4.
2569        let pos: Vec<f64> = vec![4.0; xs.len()];
2570        let r_pos = leastsq_constrained(
2571            constant,
2572            &xs,
2573            &pos,
2574            &[1.0],
2575            &[Constraint::Positive],
2576            None,
2577            DEFAULT_MAX_ITER,
2578            DEFAULT_DELTACHI,
2579        )
2580        .unwrap();
2581        assert!(
2582            (r_pos.parameters[0] - 4.0).abs() < 0.1,
2583            "recover {}",
2584            r_pos.parameters[0]
2585        );
2586    }
2587
2588    #[test]
2589    fn constrained_quoted_clamps_to_bounds() {
2590        let xs = linspace(0.0, 10.0, 21);
2591        // Target 10 is above the [0,5] bound: the fit saturates near 5.
2592        let high: Vec<f64> = vec![10.0; xs.len()];
2593        let r_hi = leastsq_constrained(
2594            constant,
2595            &xs,
2596            &high,
2597            &[2.5],
2598            &[Constraint::Quoted { min: 0.0, max: 5.0 }],
2599            None,
2600            DEFAULT_MAX_ITER,
2601            DEFAULT_DELTACHI,
2602        )
2603        .unwrap();
2604        assert!(
2605            (0.0..=5.0).contains(&r_hi.parameters[0]),
2606            "out of bounds: {}",
2607            r_hi.parameters[0]
2608        );
2609        assert!(
2610            r_hi.parameters[0] > 4.5,
2611            "did not saturate near 5: {}",
2612            r_hi.parameters[0]
2613        );
2614        // Target 3 is inside the bound and is recovered.
2615        let mid: Vec<f64> = vec![3.0; xs.len()];
2616        let r_mid = leastsq_constrained(
2617            constant,
2618            &xs,
2619            &mid,
2620            &[2.5],
2621            &[Constraint::Quoted { min: 0.0, max: 5.0 }],
2622            None,
2623            DEFAULT_MAX_ITER,
2624            DEFAULT_DELTACHI,
2625        )
2626        .unwrap();
2627        assert!(
2628            (r_mid.parameters[0] - 3.0).abs() < 0.05,
2629            "recover {}",
2630            r_mid.parameters[0]
2631        );
2632    }
2633
2634    #[test]
2635    fn constrained_factor_ties_parameters() {
2636        let xs = linspace(-5.0, 5.0, 21);
2637        // y = a*x + b with b = 2*a; true a = 3 => b = 6.
2638        let ys: Vec<f64> = xs.iter().map(|&x| 3.0 * x + 6.0).collect();
2639        let res = leastsq_constrained(
2640            line,
2641            &xs,
2642            &ys,
2643            &[1.0, 0.0],
2644            &[
2645                Constraint::Free,
2646                Constraint::Factor {
2647                    reference: 0,
2648                    factor: 2.0,
2649                },
2650            ],
2651            None,
2652            DEFAULT_MAX_ITER,
2653            DEFAULT_DELTACHI,
2654        )
2655        .unwrap();
2656        assert!(
2657            (res.parameters[0] - 3.0).abs() < 1e-4,
2658            "a {}",
2659            res.parameters[0]
2660        );
2661        assert!(
2662            (res.parameters[1] - 6.0).abs() < 1e-4,
2663            "b {}",
2664            res.parameters[1]
2665        );
2666        assert!(
2667            (res.parameters[1] - 2.0 * res.parameters[0]).abs() < 1e-9,
2668            "tie broken"
2669        );
2670    }
2671
2672    #[test]
2673    fn constrained_delta_ties_parameters() {
2674        let xs = linspace(-5.0, 5.0, 21);
2675        // b = a + 5; true a = 2 => b = 7.
2676        let ys: Vec<f64> = xs.iter().map(|&x| 2.0 * x + 7.0).collect();
2677        let res = leastsq_constrained(
2678            line,
2679            &xs,
2680            &ys,
2681            &[0.0, 0.0],
2682            &[
2683                Constraint::Free,
2684                Constraint::Delta {
2685                    reference: 0,
2686                    delta: 5.0,
2687                },
2688            ],
2689            None,
2690            DEFAULT_MAX_ITER,
2691            DEFAULT_DELTACHI,
2692        )
2693        .unwrap();
2694        assert!(
2695            (res.parameters[0] - 2.0).abs() < 1e-4,
2696            "a {}",
2697            res.parameters[0]
2698        );
2699        assert!(
2700            (res.parameters[1] - res.parameters[0] - 5.0).abs() < 1e-9,
2701            "tie broken"
2702        );
2703    }
2704
2705    #[test]
2706    fn constrained_sum_ties_parameters() {
2707        let xs = linspace(-5.0, 5.0, 21);
2708        // b = 10 - a; true a = 4 => b = 6.
2709        let ys: Vec<f64> = xs.iter().map(|&x| 4.0 * x + 6.0).collect();
2710        let res = leastsq_constrained(
2711            line,
2712            &xs,
2713            &ys,
2714            &[0.0, 0.0],
2715            &[
2716                Constraint::Free,
2717                Constraint::Sum {
2718                    reference: 0,
2719                    sum: 10.0,
2720                },
2721            ],
2722            None,
2723            DEFAULT_MAX_ITER,
2724            DEFAULT_DELTACHI,
2725        )
2726        .unwrap();
2727        assert!(
2728            (res.parameters[0] - 4.0).abs() < 1e-4,
2729            "a {}",
2730            res.parameters[0]
2731        );
2732        assert!(
2733            (res.parameters[0] + res.parameters[1] - 10.0).abs() < 1e-9,
2734            "tie broken"
2735        );
2736    }
2737
2738    #[test]
2739    fn constrained_rejects_bad_spec() {
2740        let xs = linspace(0.0, 4.0, 5);
2741        let ys = vec![1.0; 5];
2742        // constraints length mismatch.
2743        assert_eq!(
2744            leastsq_constrained(constant, &xs, &ys, &[1.0], &[], None, 10, DEFAULT_DELTACHI)
2745                .unwrap_err(),
2746            FitError::BadConstraintReference
2747        );
2748        // equal Quoted bounds (B == 0).
2749        assert_eq!(
2750            leastsq_constrained(
2751                constant,
2752                &xs,
2753                &ys,
2754                &[1.0],
2755                &[Constraint::Quoted { min: 5.0, max: 5.0 }],
2756                None,
2757                10,
2758                DEFAULT_DELTACHI,
2759            )
2760            .unwrap_err(),
2761            FitError::InvalidConstraint
2762        );
2763        // Factor referencing a non-existent parameter.
2764        assert_eq!(
2765            leastsq_constrained(
2766                line,
2767                &xs,
2768                &ys,
2769                &[1.0, 0.0],
2770                &[
2771                    Constraint::Free,
2772                    Constraint::Factor {
2773                        reference: 9,
2774                        factor: 2.0
2775                    }
2776                ],
2777                None,
2778                10,
2779                DEFAULT_DELTACHI,
2780            )
2781            .unwrap_err(),
2782            FitError::BadConstraintReference
2783        );
2784        // No free parameters at all.
2785        assert_eq!(
2786            leastsq_constrained(
2787                line,
2788                &xs,
2789                &ys,
2790                &[1.0, 2.0],
2791                &[Constraint::Fixed, Constraint::Fixed],
2792                None,
2793                10,
2794                DEFAULT_DELTACHI,
2795            )
2796            .unwrap_err(),
2797            FitError::NoFreeParameters
2798        );
2799    }
2800
2801    // --- Multi-peak (simultaneous) Gaussian fit ----------------------------
2802
2803    fn grid(n: usize) -> Vec<f64> {
2804        (0..n).map(|i| i as f64).collect()
2805    }
2806
2807    /// Find the fitted triple whose centre is nearest `target`.
2808    fn nearest_peak(params: &[f64], target: f64) -> [f64; 3] {
2809        params
2810            .chunks_exact(3)
2811            .min_by(|a, b| {
2812                (a[1] - target)
2813                    .abs()
2814                    .partial_cmp(&(b[1] - target).abs())
2815                    .unwrap()
2816            })
2817            .map(|t| [t[0], t[1], t[2]])
2818            .unwrap()
2819    }
2820
2821    #[test]
2822    fn multi_gaussian_model_is_sum_of_single_gaussians() {
2823        let xs = grid(100);
2824        // Two peaks; compare against two single gaussian_model calls (bg = 0).
2825        let a = gaussian_model(&xs, &[100.0, 30.0, 8.0, 0.0]);
2826        let b = gaussian_model(&xs, &[60.0, 70.0, 5.0, 0.0]);
2827        let sum = multi_gaussian_model(&xs, &[100.0, 30.0, 8.0, 60.0, 70.0, 5.0]);
2828        for i in 0..xs.len() {
2829            assert!((sum[i] - (a[i] + b[i])).abs() < 1e-9, "mismatch at {i}");
2830        }
2831    }
2832
2833    #[test]
2834    fn fit_multi_gaussian_recovers_two_peaks() {
2835        let xs = grid(100);
2836        let mut ys = gaussian_model(&xs, &[100.0, 30.0, 8.0, 0.0]);
2837        for (yi, g) in ys
2838            .iter_mut()
2839            .zip(gaussian_model(&xs, &[80.0, 70.0, 6.0, 0.0]))
2840        {
2841            *yi += g;
2842        }
2843        let res = fit_multi_gaussian(
2844            &xs,
2845            &ys,
2846            8.0,
2847            DEFAULT_PEAK_SENSITIVITY,
2848            DEFAULT_MAX_ITER,
2849            DEFAULT_DELTACHI,
2850        )
2851        .expect("multi-peak fit should succeed");
2852        assert!(res.parameters.len() >= 6, "expected >=2 peaks");
2853        let p1 = nearest_peak(&res.parameters, 30.0);
2854        let p2 = nearest_peak(&res.parameters, 70.0);
2855        assert!((p1[1] - 30.0).abs() < 1.0, "centre1 {}", p1[1]);
2856        assert!((p1[0] - 100.0).abs() < 5.0, "height1 {}", p1[0]);
2857        assert!((p1[2] - 8.0).abs() < 1.0, "fwhm1 {}", p1[2]);
2858        assert!((p2[1] - 70.0).abs() < 1.0, "centre2 {}", p2[1]);
2859        assert!((p2[0] - 80.0).abs() < 5.0, "height2 {}", p2[0]);
2860        assert!((p2[2] - 6.0).abs() < 1.0, "fwhm2 {}", p2[2]);
2861    }
2862
2863    #[test]
2864    fn fit_multi_gaussian_full_packages_names_errors_and_curve() {
2865        let xs = grid(100);
2866        let mut ys = gaussian_model(&xs, &[100.0, 30.0, 8.0, 0.0]);
2867        for (yi, g) in ys
2868            .iter_mut()
2869            .zip(gaussian_model(&xs, &[80.0, 70.0, 6.0, 0.0]))
2870        {
2871            *yi += g;
2872        }
2873        let ir = fit_multi_gaussian_full(
2874            &xs,
2875            &ys,
2876            8.0,
2877            DEFAULT_PEAK_SENSITIVITY,
2878            DEFAULT_MAX_ITER,
2879            DEFAULT_DELTACHI,
2880        )
2881        .expect("multi-peak fit should succeed");
2882        let n = ir.fit.parameters.len();
2883        assert!(n >= 6 && n.is_multiple_of(3), "param count {n}");
2884        // Names, values, and errors all line up for the results table.
2885        assert_eq!(ir.fit.param_names.len(), n);
2886        assert_eq!(ir.std_errors().len(), n);
2887        assert_eq!(ir.fit.y_fit.len(), xs.len());
2888        // Per-peak naming is 1-based Height/Center/FWHM triples.
2889        assert_eq!(ir.fit.param_names[0], "Height 1");
2890        assert_eq!(ir.fit.param_names[1], "Center 1");
2891        assert_eq!(ir.fit.param_names[2], "FWHM 1");
2892        // The packaged curve equals the model evaluated at the fitted params.
2893        assert_eq!(ir.fit.y_fit, multi_gaussian_model(&xs, &ir.fit.parameters));
2894        // Both peaks recovered.
2895        assert!((nearest_peak(&ir.fit.parameters, 30.0)[1] - 30.0).abs() < 1.0);
2896        assert!((nearest_peak(&ir.fit.parameters, 70.0)[1] - 70.0).abs() < 1.0);
2897    }
2898
2899    #[test]
2900    fn fit_peak_constrained_all_free_recovers_peak() {
2901        let xs = grid(100);
2902        let ys = gaussian_model(&xs, &[60.0, 45.0, 7.0, 5.0]);
2903        let cons = vec![Constraint::Free; 4];
2904        let ir = fit_peak_constrained(
2905            PeakModel::Gaussian,
2906            &xs,
2907            &ys,
2908            &cons,
2909            DEFAULT_MAX_ITER,
2910            DEFAULT_DELTACHI,
2911        )
2912        .unwrap();
2913        let p = &ir.fit.parameters;
2914        assert!((p[1] - 45.0).abs() < 1.0, "centre {}", p[1]);
2915        assert!((p[2] - 7.0).abs() < 1.0, "fwhm {}", p[2]);
2916        assert_eq!(ir.fit.param_names, PeakModel::Gaussian.param_names());
2917    }
2918
2919    #[test]
2920    fn fit_peak_constrained_fixed_holds_param_at_estimate() {
2921        let xs = grid(100);
2922        let ys = gaussian_model(&xs, &[60.0, 45.0, 7.0, 5.0]);
2923        // The fixed parameter must stay bit-identical to its estimate.
2924        let p0 = PeakModel::Gaussian.estimate(&xs, &ys).unwrap();
2925        let mut cons = vec![Constraint::Free; 4];
2926        cons[1] = Constraint::Fixed; // hold the centre
2927        let ir = fit_peak_constrained(
2928            PeakModel::Gaussian,
2929            &xs,
2930            &ys,
2931            &cons,
2932            DEFAULT_MAX_ITER,
2933            DEFAULT_DELTACHI,
2934        )
2935        .unwrap();
2936        assert_eq!(ir.fit.parameters[1], p0[1]);
2937    }
2938
2939    #[test]
2940    fn fit_peak_from_uses_explicit_p0_and_recovers() {
2941        let xs = grid(100);
2942        let ys = gaussian_model(&xs, &[60.0, 45.0, 7.0, 5.0]);
2943        // A deliberately-off initial guess; LM should still converge.
2944        let p0 = [20.0, 40.0, 12.0, 0.0];
2945        let cons = vec![Constraint::Free; 4];
2946        let ir = fit_peak_from(
2947            PeakModel::Gaussian,
2948            &xs,
2949            &ys,
2950            &p0,
2951            &cons,
2952            DEFAULT_MAX_ITER,
2953            DEFAULT_DELTACHI,
2954        )
2955        .unwrap();
2956        let p = &ir.fit.parameters;
2957        assert!((p[1] - 45.0).abs() < 1.0, "centre {}", p[1]);
2958        assert!((p[2] - 7.0).abs() < 1.0, "fwhm {}", p[2]);
2959        // fit_peak_constrained estimates p0 then delegates here, so the two
2960        // agree when started from the estimate.
2961        let est = PeakModel::Gaussian.estimate(&xs, &ys).unwrap();
2962        let via_estimate = fit_peak_from(
2963            PeakModel::Gaussian,
2964            &xs,
2965            &ys,
2966            &est,
2967            &cons,
2968            DEFAULT_MAX_ITER,
2969            DEFAULT_DELTACHI,
2970        )
2971        .unwrap();
2972        let direct = fit_peak_constrained(
2973            PeakModel::Gaussian,
2974            &xs,
2975            &ys,
2976            &cons,
2977            DEFAULT_MAX_ITER,
2978            DEFAULT_DELTACHI,
2979        )
2980        .unwrap();
2981        assert_eq!(via_estimate.fit.parameters, direct.fit.parameters);
2982    }
2983
2984    #[test]
2985    fn fit_peak_constrained_rejects_count_mismatch() {
2986        let xs = grid(50);
2987        let ys = gaussian_model(&xs, &[60.0, 25.0, 7.0, 0.0]);
2988        // Gaussian has 4 parameters; a 3-entry constraint vector is rejected.
2989        assert!(
2990            fit_peak_constrained(
2991                PeakModel::Gaussian,
2992                &xs,
2993                &ys,
2994                &[Constraint::Free; 3],
2995                DEFAULT_MAX_ITER,
2996                DEFAULT_DELTACHI,
2997            )
2998            .is_none()
2999        );
3000    }
3001
3002    #[test]
3003    fn fit_multi_gaussian_single_peak() {
3004        let xs = grid(100);
3005        let ys = gaussian_model(&xs, &[50.0, 45.0, 7.0, 0.0]);
3006        let res = fit_multi_gaussian(
3007            &xs,
3008            &ys,
3009            7.0,
3010            DEFAULT_PEAK_SENSITIVITY,
3011            DEFAULT_MAX_ITER,
3012            DEFAULT_DELTACHI,
3013        )
3014        .unwrap();
3015        let p = nearest_peak(&res.parameters, 45.0);
3016        assert!((p[0] - 50.0).abs() < 2.0, "height {}", p[0]);
3017        assert!((p[1] - 45.0).abs() < 0.5, "centre {}", p[1]);
3018        assert!((p[2] - 7.0).abs() < 0.5, "fwhm {}", p[2]);
3019    }
3020
3021    #[test]
3022    fn estimate_multi_gaussian_seeds_height_and_position() {
3023        let xs = grid(100);
3024        let ys = gaussian_model(&xs, &[50.0, 45.0, 7.0, 0.0]);
3025        let (seeds, cons) =
3026            estimate_multi_gaussian(&xs, &ys, 7.0, DEFAULT_PEAK_SENSITIVITY).unwrap();
3027        assert_eq!(seeds.len() % 3, 0);
3028        assert_eq!(seeds.len(), cons.len());
3029        // Final constraints: height Positive, centre Free, FWHM Positive.
3030        assert_eq!(cons[0], Constraint::Positive);
3031        assert_eq!(cons[1], Constraint::Free);
3032        assert_eq!(cons[2], Constraint::Positive);
3033    }
3034
3035    #[test]
3036    fn estimate_multi_gaussian_rejects_empty_and_mismatch() {
3037        assert!(estimate_multi_gaussian(&[], &[], 5.0, 2.5).is_none());
3038        assert!(estimate_multi_gaussian(&[0.0, 1.0], &[1.0], 5.0, 2.5).is_none());
3039    }
3040
3041    // --- Non-peak theories: erf, step/slit/atan models, estimators ---------
3042
3043    #[test]
3044    fn erf_matches_known_values() {
3045        // Reference values to full precision; A&S 7.1.26 is good to ~1.5e-7.
3046        assert_eq!(erf(0.0), 0.0);
3047        assert!((erf(0.5) - 0.520_499_877_813_046_5).abs() < 1e-6);
3048        assert!((erf(1.0) - 0.842_700_792_949_714_9).abs() < 1e-6);
3049        assert!((erf(2.0) - 0.995_322_265_018_952_7).abs() < 1e-6);
3050        // Odd symmetry.
3051        assert!((erf(-1.0) + erf(1.0)).abs() < 1e-12);
3052    }
3053
3054    #[test]
3055    fn erfc_is_one_minus_erf() {
3056        for &x in &[-2.0, -0.3, 0.0, 0.7, 1.5] {
3057            assert!((erfc(x) - (1.0 - erf(x))).abs() < 1e-15);
3058        }
3059    }
3060
3061    #[test]
3062    fn convolve_valid_matches_numpy_reversed_kernel() {
3063        // numpy reverses the kernel: out[k] = sum_j y[k+j] * kernel[m-1-j].
3064        // [1,0,0] picks y[k+2], so valid output is [y[2], y[3]].
3065        assert_eq!(
3066            convolve_valid(&[1.0, 2.0, 3.0, 4.0], &[1.0, 0.0, 0.0]),
3067            vec![3.0, 4.0]
3068        );
3069        // Step-up edge kernel on a ramp: single 'valid' output = 2.5.
3070        let out = convolve_valid(&[1.0, 2.0, 3.0, 4.0, 5.0], &[0.25, 0.75, 0.0, -0.75, -0.25]);
3071        assert_eq!(out.len(), 1);
3072        assert!((out[0] - 2.5).abs() < 1e-12);
3073        // Kernel longer than data → empty.
3074        assert!(convolve_valid(&[1.0], &[1.0, 2.0]).is_empty());
3075    }
3076
3077    #[test]
3078    fn stepup_model_half_height_at_center_and_asymptotes() {
3079        let p = [10.0, 0.0, 4.0, 2.0]; // height, center, fwhm, bg
3080        // erf(0)=0 exactly → exactly bg + height/2 at the centre.
3081        assert_eq!(stepup_model(&[0.0], &p)[0], 7.0);
3082        // Asymptotes: bg on the far left, bg+height on the far right.
3083        assert!((stepup_model(&[-1000.0], &p)[0] - 2.0).abs() < 1e-9);
3084        assert!((stepup_model(&[1000.0], &p)[0] - 12.0).abs() < 1e-9);
3085        // Monotone increasing.
3086        let xs = linspace(-20.0, 20.0, 81);
3087        let ys = stepup_model(&xs, &p);
3088        assert!(ys.windows(2).all(|w| w[1] >= w[0]));
3089    }
3090
3091    #[test]
3092    fn stepdown_model_half_height_at_center_and_asymptotes() {
3093        let p = [10.0, 0.0, 4.0, 2.0];
3094        assert_eq!(stepdown_model(&[0.0], &p)[0], 7.0); // erfc(0)=1
3095        assert!((stepdown_model(&[-1000.0], &p)[0] - 12.0).abs() < 1e-9);
3096        assert!((stepdown_model(&[1000.0], &p)[0] - 2.0).abs() < 1e-9);
3097        let xs = linspace(-20.0, 20.0, 81);
3098        let ys = stepdown_model(&xs, &p);
3099        assert!(ys.windows(2).all(|w| w[1] <= w[0]));
3100    }
3101
3102    #[test]
3103    fn atan_stepup_model_half_height_at_center_and_monotonic() {
3104        let p = [10.0, 0.0, 4.0, 2.0]; // height, position, width, bg
3105        assert_eq!(atan_stepup_model(&[0.0], &p)[0], 7.0); // atan(0)=0
3106        assert!((atan_stepup_model(&[-1.0e8], &p)[0] - 2.0).abs() < 1e-6);
3107        assert!((atan_stepup_model(&[1.0e8], &p)[0] - 12.0).abs() < 1e-6);
3108        let xs = linspace(-50.0, 50.0, 101);
3109        let ys = atan_stepup_model(&xs, &p);
3110        assert!(ys.windows(2).all(|w| w[1] >= w[0]));
3111    }
3112
3113    #[test]
3114    fn slit_model_is_symmetric_and_localised() {
3115        let p = [10.0, 0.0, 10.0, 2.0, 1.0]; // height, position, fwhm, beamfwhm, bg
3116        // Symmetric about the position.
3117        for &d in &[1.0, 3.0, 7.0, 12.0] {
3118            let a = slit_model(&[d], &p)[0];
3119            let b = slit_model(&[-d], &p)[0];
3120            assert!((a - b).abs() < 1e-12, "slit asymmetric at {d}: {a} vs {b}");
3121        }
3122        // Inside the slit it sits above the background; far outside it is bg.
3123        assert!(slit_model(&[0.0], &p)[0] > 5.0);
3124        assert!((slit_model(&[1000.0], &p)[0] - 1.0).abs() < 1e-9);
3125        assert!((slit_model(&[-1000.0], &p)[0] - 1.0).abs() < 1e-9);
3126    }
3127
3128    #[test]
3129    fn estimate_stepup_recovers_center_height_bg() {
3130        let xs = linspace(0.0, 100.0, 101);
3131        let ys = stepup_model(&xs, &[8.0, 40.0, 6.0, 3.0]);
3132        let s = estimate_stepup(&xs, &ys).unwrap();
3133        assert!((s[0] - 8.0).abs() < 0.5, "height seed {}", s[0]);
3134        assert!((s[1] - 40.0).abs() < 3.0, "center seed {}", s[1]);
3135        assert!((s[3] - 3.0).abs() < 0.5, "bg seed {}", s[3]);
3136    }
3137
3138    #[test]
3139    fn estimate_stepdown_recovers_center_height_bg() {
3140        let xs = linspace(0.0, 100.0, 101);
3141        let ys = stepdown_model(&xs, &[8.0, 40.0, 6.0, 3.0]);
3142        let s = estimate_stepdown(&xs, &ys).unwrap();
3143        assert!((s[0] - 8.0).abs() < 0.5, "height seed {}", s[0]);
3144        assert!((s[1] - 40.0).abs() < 3.0, "center seed {}", s[1]);
3145        assert!((s[3] - 3.0).abs() < 0.5, "bg seed {}", s[3]);
3146    }
3147
3148    #[test]
3149    fn estimate_slit_centers_on_the_slit() {
3150        let xs = linspace(0.0, 100.0, 201);
3151        let ys = slit_model(&xs, &[10.0, 50.0, 20.0, 3.0, 2.0]);
3152        let s = estimate_slit(&xs, &ys).unwrap();
3153        assert_eq!(s.len(), 5);
3154        assert!((s[1] - 50.0).abs() < 2.0, "position seed {}", s[1]);
3155        assert!(s[3] > 0.0, "beamfwhm seed must be positive: {}", s[3]);
3156    }
3157
3158    #[test]
3159    fn peak_model_step_variants_delegate() {
3160        assert_eq!(PeakModel::StepUp.name(), "Step Up");
3161        assert_eq!(PeakModel::StepDown.name(), "Step Down");
3162        assert_eq!(PeakModel::Slit.name(), "Slit");
3163        assert_eq!(PeakModel::AtanStepUp.name(), "Arctan Step Up");
3164        assert_eq!(
3165            PeakModel::StepUp.param_names(),
3166            vec!["Height", "Center", "FWHM", "Background"]
3167        );
3168        assert_eq!(PeakModel::Slit.param_names().len(), 5);
3169        assert_eq!(
3170            PeakModel::AtanStepUp.param_names(),
3171            vec!["Height", "Center", "Width", "Background"]
3172        );
3173        // eval delegates to the matching model function.
3174        let xs = linspace(-5.0, 5.0, 11);
3175        let p = [3.0, 0.0, 2.0, 1.0];
3176        assert_eq!(PeakModel::StepUp.eval(&xs, &p), stepup_model(&xs, &p));
3177        assert_eq!(PeakModel::StepDown.eval(&xs, &p), stepdown_model(&xs, &p));
3178        assert_eq!(
3179            PeakModel::AtanStepUp.eval(&xs, &p),
3180            atan_stepup_model(&xs, &p)
3181        );
3182    }
3183
3184    #[test]
3185    fn iterative_fit_recovers_stepup() {
3186        let xs = linspace(0.0, 100.0, 101);
3187        let truth = [8.0, 40.0, 6.0, 3.0];
3188        let ys = stepup_model(&xs, &truth);
3189        let fit = IterativeFit::new(PeakModel::StepUp)
3190            .fit_full(&xs, &ys)
3191            .unwrap();
3192        let p = &fit.fit.parameters;
3193        assert!((p[0] - truth[0]).abs() < 1.0, "height {}", p[0]);
3194        assert!((p[1] - truth[1]).abs() < 1.0, "center {}", p[1]);
3195        assert!((p[3] - truth[3]).abs() < 1.0, "bg {}", p[3]);
3196    }
3197
3198    // --- Peak-on-background fit (silx background-then-peak workflow) --------
3199
3200    #[test]
3201    fn fit_peak_with_background_none_is_byte_identical_to_plain_fit() {
3202        use crate::core::background::Background;
3203        let xs = grid(100);
3204        let ys = gaussian_model(&xs, &[100.0, 50.0, 8.0, 0.0]);
3205        let bgfit = fit_peak_with_background(
3206            PeakModel::Gaussian,
3207            Background::None,
3208            &xs,
3209            &ys,
3210            DEFAULT_MAX_ITER,
3211            DEFAULT_DELTACHI,
3212        )
3213        .unwrap();
3214        let plain = IterativeFit::new(PeakModel::Gaussian)
3215            .fit_full(&xs, &ys)
3216            .unwrap();
3217        // No background: residual == y exactly, so the peak fit and the total
3218        // curve match the plain iterative fit bit-for-bit.
3219        assert!(bgfit.background.iter().all(|&b| b == 0.0));
3220        assert_eq!(bgfit.peak.fit.parameters, plain.fit.parameters);
3221        assert_eq!(bgfit.total, bgfit.peak.fit.y_fit);
3222        assert_eq!(bgfit.total, plain.fit.y_fit);
3223    }
3224
3225    #[test]
3226    fn fit_peak_with_background_constant_offset_recovers_peak() {
3227        use crate::core::background::Background;
3228        let xs = grid(100);
3229        // Gaussian sitting on a +25 constant pedestal (the gaussian model's
3230        // trailing parameter is its own constant background).
3231        let ys = gaussian_model(&xs, &[100.0, 50.0, 8.0, 25.0]);
3232        let bgfit = fit_peak_with_background(
3233            PeakModel::Gaussian,
3234            Background::Constant,
3235            &xs,
3236            &ys,
3237            DEFAULT_MAX_ITER,
3238            DEFAULT_DELTACHI,
3239        )
3240        .unwrap();
3241        // Constant background = min(y) ≈ the 25 pedestal (tails approach it).
3242        assert!(
3243            (bgfit.background[0] - 25.0).abs() < 1.0,
3244            "background {}",
3245            bgfit.background[0]
3246        );
3247        assert!(bgfit.background.iter().all(|&b| b == bgfit.background[0]));
3248        // Centre recovered, and the reconstructed total tracks the data.
3249        let p = &bgfit.peak.fit.parameters;
3250        assert!((p[1] - 50.0).abs() < 2.0, "centre {}", p[1]);
3251        let max_err = ys
3252            .iter()
3253            .zip(&bgfit.total)
3254            .map(|(&d, &t)| (d - t).abs())
3255            .fold(0.0_f64, f64::max);
3256        assert!(max_err < 5.0, "max |data - total| = {max_err}");
3257    }
3258
3259    #[test]
3260    fn fit_peak_with_background_linear_recovers_peak_on_slope() {
3261        use crate::core::background::Background;
3262        let xs = grid(120);
3263        // Gaussian on a rising line y = 0.5*x + 10.
3264        let base = line(&xs, &[0.5, 10.0]);
3265        let peak = gaussian_model(&xs, &[80.0, 60.0, 8.0, 0.0]);
3266        let ys: Vec<f64> = base.iter().zip(&peak).map(|(&b, &p)| b + p).collect();
3267        let bgfit = fit_peak_with_background(
3268            PeakModel::Gaussian,
3269            Background::Linear,
3270            &xs,
3271            &ys,
3272            DEFAULT_MAX_ITER,
3273            DEFAULT_DELTACHI,
3274        )
3275        .unwrap();
3276        // The fitted background follows the positive slope.
3277        assert!(
3278            *bgfit.background.last().unwrap() > bgfit.background[0],
3279            "background not rising: {} -> {}",
3280            bgfit.background[0],
3281            bgfit.background.last().unwrap()
3282        );
3283        // Peak centre and width recovered on the residual.
3284        let p = &bgfit.peak.fit.parameters;
3285        assert!((p[1] - 60.0).abs() < 3.0, "centre {}", p[1]);
3286        assert!((p[2] - 8.0).abs() < 4.0, "fwhm {}", p[2]);
3287        // Total tracks the data (peak height 80 → allow a modest residual).
3288        let max_err = ys
3289            .iter()
3290            .zip(&bgfit.total)
3291            .map(|(&d, &t)| (d - t).abs())
3292            .fold(0.0_f64, f64::max);
3293        assert!(max_err < 12.0, "max |data - total| = {max_err}");
3294    }
3295
3296    #[test]
3297    fn fit_peak_with_background_rejects_bad_input() {
3298        use crate::core::background::Background;
3299        let xs = grid(10);
3300        let ys = constant(&xs, &[1.0]);
3301        // Length mismatch.
3302        assert!(
3303            fit_peak_with_background(
3304                PeakModel::Gaussian,
3305                Background::None,
3306                &xs,
3307                &ys[..5],
3308                DEFAULT_MAX_ITER,
3309                DEFAULT_DELTACHI,
3310            )
3311            .is_none()
3312        );
3313        // Empty input.
3314        assert!(
3315            fit_peak_with_background(
3316                PeakModel::Gaussian,
3317                Background::None,
3318                &[],
3319                &[],
3320                DEFAULT_MAX_ITER,
3321                DEFAULT_DELTACHI,
3322            )
3323            .is_none()
3324        );
3325    }
3326}