Skip to main content

siplot/widget/
fit_widget.rs

1use egui::Color32;
2use egui_wgpu::RenderState;
3
4use crate::core::backend::ItemHandle;
5use crate::core::background::{
6    Background, DEFAULT_SNIP_WIDTH, DEFAULT_STRIP_ITERATIONS, DEFAULT_STRIP_THRESHOLD_FACTOR,
7    DEFAULT_STRIP_WIDTH,
8};
9use crate::core::fitting::{
10    Constraint, DEFAULT_DELTACHI, DEFAULT_MAX_ITER, FitFunction, FitResult, GaussianEstimateFit,
11    IterativeFit, IterativeFitResult, LinearFit, PeakModel, fit_multi_gaussian_full,
12    fit_peak_constrained, fit_peak_from, fit_peak_with_background,
13};
14use crate::core::peaks::{DEFAULT_PEAK_SENSITIVITY, guess_fwhm};
15use crate::core::plot::PlotId;
16use crate::render::gpu_curve::CurveData;
17use crate::widget::high_level::Plot1D;
18
19/// Format a fitted parameter value together with its estimated error as
20/// `value ± error`, mirroring the silx `FitWidget` results table which shows a
21/// value and its sigma (the square root of the covariance diagonal).
22///
23/// A non-finite error is rendered without the `±` term (silx leaves the
24/// uncertainty blank when it cannot be computed).
25pub fn format_param_value_error(value: f64, error: f64) -> String {
26    if error.is_finite() {
27        format!("{value:.6} ± {error:.6}")
28    } else {
29        format!("{value:.6}")
30    }
31}
32
33/// Format the reduced chi-square goodness-of-fit metric for the results table
34/// (silx `FitWidget` shows `chisq` / reduced chi-square). `None` (non-positive
35/// degrees of freedom) renders as `N/A`.
36pub fn format_reduced_chisq(reduced_chisq: Option<f64>) -> String {
37    match reduced_chisq {
38        Some(rc) if rc.is_finite() => format!("{rc:.6}"),
39        _ => "N/A".to_string(),
40    }
41}
42
43/// The finite x extent of `x_data` as a `(min, max)` fit window, or `(0.0, 1.0)`
44/// when there is no finite sample. Used to seed the FitWidget's xmin/xmax when
45/// the user first enables range limiting (silx defaults them to the curve's x
46/// range).
47fn default_fit_range_of(x_data: &[f64]) -> (f64, f64) {
48    let mut it = x_data.iter().copied().filter(|v| v.is_finite());
49    match it.next() {
50        Some(first) => {
51            let (mut lo, mut hi) = (first, first);
52            for v in it {
53                lo = lo.min(v);
54                hi = hi.max(v);
55            }
56            (lo, hi)
57        }
58        None => (0.0, 1.0),
59    }
60}
61
62/// The background theories offered by the [`FitWidget`] background combo, in
63/// silx `bgtheories.THEORY` order, each paired with its silx display label.
64const BACKGROUND_CHOICES: [(Background, &str); 9] = [
65    (Background::None, "No Background"),
66    (Background::Constant, "Constant"),
67    (Background::Linear, "Linear"),
68    (
69        Background::Strip {
70            width: DEFAULT_STRIP_WIDTH,
71            niterations: DEFAULT_STRIP_ITERATIONS,
72            factor: DEFAULT_STRIP_THRESHOLD_FACTOR,
73        },
74        "Strip",
75    ),
76    (
77        Background::Snip {
78            width: DEFAULT_SNIP_WIDTH,
79        },
80        "Snip",
81    ),
82    (Background::Polynomial { degree: 2 }, "Degree 2 Polynomial"),
83    (Background::Polynomial { degree: 3 }, "Degree 3 Polynomial"),
84    (Background::Polynomial { degree: 4 }, "Degree 4 Polynomial"),
85    (Background::Polynomial { degree: 5 }, "Degree 5 Polynomial"),
86];
87
88/// The combo label for `background`: its [`BACKGROUND_CHOICES`] entry, or the
89/// generic [`Background::name`] when it is a non-default parameterisation.
90fn background_label(background: Background) -> &'static str {
91    BACKGROUND_CHOICES
92        .iter()
93        .find(|(bg, _)| *bg == background)
94        .map(|(_, label)| *label)
95        .unwrap_or_else(|| background.name())
96}
97
98/// A constraint "code" the user picks in the parameter table, without its
99/// payload — silx `Parameters.code_options` (`Parameters.py:205-215`). This is
100/// what the combo selects; the payload (`QUOTED` min/max, `FACTOR`/`DELTA`/`SUM`
101/// reference + value) is then edited in the adjacent fields.
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103enum ConstraintKind {
104    /// `FREE` — no restriction.
105    Free,
106    /// `POSITIVE` — kept positive.
107    Positive,
108    /// `QUOTED` — confined to a `[min, max]` interval.
109    Quoted,
110    /// `FIXED` — held at its starting value.
111    Fixed,
112    /// `FACTOR` — tied to another parameter by a multiplier.
113    Factor,
114    /// `DELTA` — tied to another parameter by an additive offset.
115    Delta,
116    /// `SUM` — the pair tied to a constant sum.
117    Sum,
118    /// `IGNORE` — held and stripped from the model call.
119    Ignore,
120}
121
122/// silx `Parameters.code_options` display string for a [`ConstraintKind`].
123fn constraint_kind_label(kind: ConstraintKind) -> &'static str {
124    match kind {
125        ConstraintKind::Free => "FREE",
126        ConstraintKind::Positive => "POSITIVE",
127        ConstraintKind::Quoted => "QUOTED",
128        ConstraintKind::Fixed => "FIXED",
129        ConstraintKind::Factor => "FACTOR",
130        ConstraintKind::Delta => "DELTA",
131        ConstraintKind::Sum => "SUM",
132        ConstraintKind::Ignore => "IGNORE",
133    }
134}
135
136/// The code of an existing [`Constraint`] (drops its payload).
137fn constraint_kind(constraint: Constraint) -> ConstraintKind {
138    match constraint {
139        Constraint::Free => ConstraintKind::Free,
140        Constraint::Positive => ConstraintKind::Positive,
141        Constraint::Quoted { .. } => ConstraintKind::Quoted,
142        Constraint::Fixed => ConstraintKind::Fixed,
143        Constraint::Factor { .. } => ConstraintKind::Factor,
144        Constraint::Delta { .. } => ConstraintKind::Delta,
145        Constraint::Sum { .. } => ConstraintKind::Sum,
146        Constraint::Ignored => ConstraintKind::Ignore,
147    }
148}
149
150/// The constraint codes offered in the editor combo — silx
151/// `Parameters.code_options` minus the group-management `ADD`/`SHOW` pseudo-codes
152/// and `IGNORE`. siplot fits a single model (one parameter group), so there is
153/// no redundant grouped parameter for `IGNORE` to drop, and no second group for
154/// `ADD`; the remaining seven are the per-parameter constraints
155/// `core::fitting::leastsq_constrained` enforces.
156const UI_CONSTRAINT_KINDS: [ConstraintKind; 7] = [
157    ConstraintKind::Free,
158    ConstraintKind::Positive,
159    ConstraintKind::Quoted,
160    ConstraintKind::Fixed,
161    ConstraintKind::Factor,
162    ConstraintKind::Delta,
163    ConstraintKind::Sum,
164];
165
166/// Whether `constraint` ties a parameter to another (`FACTOR`/`DELTA`/`SUM`) or
167/// drops it (`IGNORE`) — such a parameter cannot itself be the *reference* of a
168/// tie (silx `getRelatedCandidates` excludes these, `Parameters.py:578-583`).
169fn is_tied(constraint: Constraint) -> bool {
170    matches!(
171        constraint,
172        Constraint::Factor { .. }
173            | Constraint::Delta { .. }
174            | Constraint::Sum { .. }
175            | Constraint::Ignored
176    )
177}
178
179/// The "best" related parameter for a `FACTOR`/`DELTA`/`SUM` tie on
180/// `param_index`, mirroring silx `Parameters.getRelatedCandidates`
181/// (`Parameters.py:565-600`): the first *other* parameter whose own constraint
182/// is not itself a tie or `IGNORE` (you cannot chain ties). Returns `None` when
183/// no candidate exists — silx `setCodeValue` rejects the change in that case
184/// (`Parameters.py:477-479`).
185///
186/// silx additionally prefers the previous `relatedto` or a parameter sharing the
187/// same base name; the single-peak models this editor serves have distinct
188/// parameter names, so that refinement collapses to the first candidate.
189fn default_related_reference(param_index: usize, constraints: &[Constraint]) -> Option<usize> {
190    (0..constraints.len()).find(|&j| j != param_index && !is_tied(constraints[j]))
191}
192
193/// Build the [`Constraint`] for a newly-selected [`ConstraintKind`] on
194/// parameter `param_index`, seeding silx defaults. `FACTOR`/`DELTA`/`SUM` need a
195/// related parameter ([`default_related_reference`]); when none exists this
196/// returns `None`, mirroring silx `setCodeValue` rejecting the selection. The
197/// `QUOTED` seed `[0, 1]` is a placeholder the user edits in the min/max fields
198/// (silx seeds from the fit theory's estimate, which this manual editor lacks).
199fn make_constraint(
200    kind: ConstraintKind,
201    param_index: usize,
202    constraints: &[Constraint],
203) -> Option<Constraint> {
204    Some(match kind {
205        ConstraintKind::Free => Constraint::Free,
206        ConstraintKind::Positive => Constraint::Positive,
207        ConstraintKind::Quoted => Constraint::Quoted { min: 0.0, max: 1.0 },
208        ConstraintKind::Fixed => Constraint::Fixed,
209        ConstraintKind::Ignore => Constraint::Ignored,
210        ConstraintKind::Factor => Constraint::Factor {
211            reference: default_related_reference(param_index, constraints)?,
212            factor: 1.0,
213        },
214        ConstraintKind::Delta => Constraint::Delta {
215            reference: default_related_reference(param_index, constraints)?,
216            delta: 0.0,
217        },
218        ConstraintKind::Sum => Constraint::Sum {
219            reference: default_related_reference(param_index, constraints)?,
220            sum: 0.0,
221        },
222    })
223}
224
225/// A combo that picks the *reference* parameter for a `FACTOR`/`DELTA`/`SUM`
226/// tie on `param_index`, offering every other parameter not itself tied/ignored
227/// (`tieable[j]`), shown by name (silx `relatedto` candidate list).
228fn reference_param_combo(
229    ui: &mut egui::Ui,
230    param_index: usize,
231    reference: &mut usize,
232    names: &[String],
233    tieable: &[bool],
234) {
235    let selected = names.get(*reference).map(String::as_str).unwrap_or("?");
236    egui::ComboBox::from_id_salt(("fit_ref_combo", param_index))
237        .selected_text(selected)
238        .show_ui(ui, |ui| {
239            for (j, nm) in names.iter().enumerate() {
240                if tieable.get(j).copied().unwrap_or(false) {
241                    ui.selectable_value(reference, j, nm.as_str());
242                }
243            }
244        });
245}
246
247/// The selectable fit model in [`FitWidget`].
248///
249/// The first two variants preserve the original analytical fits (Linear and
250/// the analytical Gaussian estimate); the remaining variants drive the
251/// iterative Levenberg-Marquardt path with a results table that includes
252/// per-parameter errors and reduced chi-square.
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum FitModelChoice {
255    /// Analytical linear fit (`LinearFit`).
256    Linear,
257    /// Analytical Gaussian estimate (`GaussianEstimateFit`).
258    GaussianEstimate,
259    /// Iterative Gaussian (height parameterisation).
260    IterativeGaussian,
261    /// Iterative Gaussian (area parameterisation).
262    IterativeGaussianArea,
263    /// Iterative asymmetric (split) Gaussian.
264    IterativeSplitGaussian,
265    /// Iterative Lorentzian.
266    IterativeLorentzian,
267    /// Iterative Lorentzian (area parameterisation).
268    IterativeLorentzianArea,
269    /// Iterative asymmetric (split) Lorentzian.
270    IterativeSplitLorentzian,
271    /// Iterative pseudo-Voigt.
272    IterativePseudoVoigt,
273    /// Iterative pseudo-Voigt (area parameterisation).
274    IterativeAreaPseudoVoigt,
275    /// Iterative asymmetric (split) pseudo-Voigt.
276    IterativeSplitPseudoVoigt,
277    /// Iterative split pseudo-Voigt with per-side eta.
278    IterativeSplitPseudoVoigt2,
279    /// Iterative step down (descending erf edge).
280    IterativeStepDown,
281    /// Iterative step up (ascending erf edge).
282    IterativeStepUp,
283    /// Iterative slit (rising then falling edges).
284    IterativeSlit,
285    /// Iterative arctan step up.
286    IterativeAtanStepUp,
287    /// Iterative Hypermet (Gaussian + short tail + long tail + step).
288    IterativeHypermet,
289    /// Degree-2 polynomial fit.
290    IterativePolynomial2,
291    /// Degree-3 polynomial fit.
292    IterativePolynomial3,
293    /// Degree-4 polynomial fit.
294    IterativePolynomial4,
295    /// Degree-5 polynomial fit.
296    IterativePolynomial5,
297    /// Multi-peak Gaussian fit with automatic peak search (silx `sum_gauss`
298    /// theory): locate N peaks and fit them simultaneously.
299    MultiGaussian,
300}
301
302impl FitModelChoice {
303    /// All choices, in display order.
304    pub const ALL: [FitModelChoice; 22] = [
305        FitModelChoice::Linear,
306        FitModelChoice::GaussianEstimate,
307        FitModelChoice::IterativeGaussian,
308        FitModelChoice::IterativeGaussianArea,
309        FitModelChoice::IterativeSplitGaussian,
310        FitModelChoice::IterativeLorentzian,
311        FitModelChoice::IterativeLorentzianArea,
312        FitModelChoice::IterativeSplitLorentzian,
313        FitModelChoice::IterativePseudoVoigt,
314        FitModelChoice::IterativeAreaPseudoVoigt,
315        FitModelChoice::IterativeSplitPseudoVoigt,
316        FitModelChoice::IterativeSplitPseudoVoigt2,
317        FitModelChoice::IterativeStepDown,
318        FitModelChoice::IterativeStepUp,
319        FitModelChoice::IterativeSlit,
320        FitModelChoice::IterativeAtanStepUp,
321        FitModelChoice::IterativeHypermet,
322        FitModelChoice::IterativePolynomial2,
323        FitModelChoice::IterativePolynomial3,
324        FitModelChoice::IterativePolynomial4,
325        FitModelChoice::IterativePolynomial5,
326        FitModelChoice::MultiGaussian,
327    ];
328
329    /// Display name for the combo box.
330    pub fn label(self) -> &'static str {
331        match self {
332            FitModelChoice::Linear => "Linear",
333            FitModelChoice::GaussianEstimate => "Gaussian (Estimate)",
334            FitModelChoice::IterativeGaussian => "Gaussian (Iterative)",
335            FitModelChoice::IterativeGaussianArea => "Gaussian Area (Iterative)",
336            FitModelChoice::IterativeSplitGaussian => "Split Gaussian (Iterative)",
337            FitModelChoice::IterativeLorentzian => "Lorentzian (Iterative)",
338            FitModelChoice::IterativeLorentzianArea => "Lorentzian Area (Iterative)",
339            FitModelChoice::IterativeSplitLorentzian => "Split Lorentzian (Iterative)",
340            FitModelChoice::IterativePseudoVoigt => "Pseudo-Voigt (Iterative)",
341            FitModelChoice::IterativeAreaPseudoVoigt => "Pseudo-Voigt Area (Iterative)",
342            FitModelChoice::IterativeSplitPseudoVoigt => "Split Pseudo-Voigt (Iterative)",
343            FitModelChoice::IterativeSplitPseudoVoigt2 => "Split Pseudo-Voigt 2 (Iterative)",
344            FitModelChoice::IterativeStepDown => "Step Down (Iterative)",
345            FitModelChoice::IterativeStepUp => "Step Up (Iterative)",
346            FitModelChoice::IterativeSlit => "Slit (Iterative)",
347            FitModelChoice::IterativeAtanStepUp => "Arctan Step Up (Iterative)",
348            FitModelChoice::IterativeHypermet => "Hypermet (Iterative)",
349            FitModelChoice::IterativePolynomial2 => "Degree 2 Polynomial",
350            FitModelChoice::IterativePolynomial3 => "Degree 3 Polynomial",
351            FitModelChoice::IterativePolynomial4 => "Degree 4 Polynomial",
352            FitModelChoice::IterativePolynomial5 => "Degree 5 Polynomial",
353            FitModelChoice::MultiGaussian => "Gaussians (Multi-peak)",
354        }
355    }
356
357    /// The [`PeakModel`] this choice maps to, if it is one of the iterative
358    /// models.
359    pub fn peak_model(self) -> Option<PeakModel> {
360        match self {
361            FitModelChoice::IterativeGaussian => Some(PeakModel::Gaussian),
362            FitModelChoice::IterativeGaussianArea => Some(PeakModel::GaussianArea),
363            FitModelChoice::IterativeSplitGaussian => Some(PeakModel::SplitGaussian),
364            FitModelChoice::IterativeLorentzian => Some(PeakModel::Lorentzian),
365            FitModelChoice::IterativeLorentzianArea => Some(PeakModel::LorentzianArea),
366            FitModelChoice::IterativeSplitLorentzian => Some(PeakModel::SplitLorentzian),
367            FitModelChoice::IterativePseudoVoigt => Some(PeakModel::PseudoVoigt),
368            FitModelChoice::IterativeAreaPseudoVoigt => Some(PeakModel::AreaPseudoVoigt),
369            FitModelChoice::IterativeSplitPseudoVoigt => Some(PeakModel::SplitPseudoVoigt),
370            FitModelChoice::IterativeSplitPseudoVoigt2 => Some(PeakModel::SplitPseudoVoigt2),
371            FitModelChoice::IterativeStepDown => Some(PeakModel::StepDown),
372            FitModelChoice::IterativeStepUp => Some(PeakModel::StepUp),
373            FitModelChoice::IterativeSlit => Some(PeakModel::Slit),
374            FitModelChoice::IterativeAtanStepUp => Some(PeakModel::AtanStepUp),
375            FitModelChoice::IterativeHypermet => Some(PeakModel::Hypermet),
376            FitModelChoice::IterativePolynomial2 => Some(PeakModel::Polynomial2),
377            FitModelChoice::IterativePolynomial3 => Some(PeakModel::Polynomial3),
378            FitModelChoice::IterativePolynomial4 => Some(PeakModel::Polynomial4),
379            FitModelChoice::IterativePolynomial5 => Some(PeakModel::Polynomial5),
380            // Composite / analytical choices have no single peak model.
381            FitModelChoice::Linear
382            | FitModelChoice::GaussianEstimate
383            | FitModelChoice::MultiGaussian => None,
384        }
385    }
386}
387
388/// A window widget to perform curve fitting on 1D data and display the result.
389pub struct FitWidget {
390    plot: Plot1D,
391    data_handle: Option<ItemHandle>,
392    fit_handle: Option<ItemHandle>,
393    win: crate::widget::detached::DetachedWindow,
394    open: bool,
395
396    // Data
397    x_data: Vec<f64>,
398    y_data: Vec<f64>,
399
400    // Fit state
401    selected_function_idx: usize,
402    fit_result: Option<FitResult>,
403
404    // Iterative-fit state (Wave 5, additive).
405    selected_choice: FitModelChoice,
406    iterative_result: Option<IterativeFitResult>,
407    /// Optional fit range `[xmin, xmax]`; `None` fits the whole curve
408    /// (silx `FitWidget` xmin/xmax).
409    fit_range: Option<(f64, f64)>,
410    /// Background theory subtracted before an iterative peak fit (silx
411    /// `FitWidget` background combo). `None` fits the raw data unchanged.
412    background: Background,
413    /// Per-parameter constraints for the current single-peak model (silx
414    /// `FitWidget` parameter table). Resynced (cleared to all-`Free`) whenever
415    /// the selected model's parameter count changes; empty until first synced.
416    constraints: Vec<Constraint>,
417    /// Editable initial parameters for the current single-peak model (silx
418    /// `FitWidget` parameter table value column). `None` until the first fit
419    /// populates it; the next fit then starts from these (possibly edited)
420    /// values. Reset on data or model change.
421    initial_params: Option<Vec<f64>>,
422}
423
424impl FitWidget {
425    /// Create a new FitWidget with a backing Plot1D.
426    pub fn new(render_state: &RenderState, plot_id: PlotId) -> Self {
427        let mut plot = Plot1D::new(render_state, plot_id);
428        plot.set_graph_title("Fit Result");
429
430        Self {
431            plot,
432            data_handle: None,
433            fit_handle: None,
434            win: crate::widget::detached::DetachedWindow::new(
435                egui::Id::new(plot_id).with("fit_widget"),
436                egui::vec2(600.0, 400.0),
437            ),
438            open: false,
439            x_data: Vec::new(),
440            y_data: Vec::new(),
441            selected_function_idx: 0,
442            fit_result: None,
443            selected_choice: FitModelChoice::Linear,
444            iterative_result: None,
445            fit_range: None,
446            background: Background::None,
447            constraints: Vec::new(),
448            initial_params: None,
449        }
450    }
451
452    /// The default fit window when the user first enables range limiting: the
453    /// data's finite x extent (silx initialises xmin/xmax from the active
454    /// curve's x range).
455    fn default_fit_range(&self) -> (f64, f64) {
456        default_fit_range_of(&self.x_data)
457    }
458
459    /// Set the fit range `[xmin, xmax]`; only points inside it are fitted
460    /// (silx `FitWidget` xmin/xmax). Pass `None` to fit the whole curve.
461    pub fn set_fit_range(&mut self, range: Option<(f64, f64)>) {
462        self.fit_range = range;
463    }
464
465    /// The currently selected fit model choice.
466    pub fn selected_choice(&self) -> FitModelChoice {
467        self.selected_choice
468    }
469
470    /// Set the selected fit model choice.
471    pub fn set_selected_choice(&mut self, choice: FitModelChoice) {
472        self.selected_choice = choice;
473    }
474
475    /// The background theory subtracted before an iterative peak fit (silx
476    /// `FitWidget` background combo).
477    pub fn fit_background(&self) -> Background {
478        self.background
479    }
480
481    /// Set the background theory subtracted before an iterative peak fit. The
482    /// analytical Linear / Gaussian-estimate choices ignore it; iterative peak
483    /// models fit the background-subtracted residual and display the
484    /// reconstructed total curve.
485    pub fn set_fit_background(&mut self, background: Background) {
486        self.background = background;
487    }
488
489    /// The per-parameter constraints applied to the current single-peak model
490    /// (silx `FitWidget` parameter table). Empty until first synced.
491    pub fn param_constraints(&self) -> &[Constraint] {
492        &self.constraints
493    }
494
495    /// Set the per-parameter constraints for the current single-peak model. The
496    /// vector is resynced to all-`Free` if its length stops matching the
497    /// selected model's parameter count.
498    pub fn set_param_constraints(&mut self, constraints: Vec<Constraint>) {
499        self.constraints = constraints;
500    }
501
502    /// The editable initial parameters for the current single-peak model, once a
503    /// fit has populated them (silx `FitWidget` parameter table value column).
504    pub fn initial_params(&self) -> Option<&[f64]> {
505        self.initial_params.as_deref()
506    }
507
508    /// Set the initial parameters the next single-peak fit starts from. Dropped
509    /// if the length stops matching the selected model's parameter count.
510    pub fn set_initial_params(&mut self, params: Option<Vec<f64>>) {
511        self.initial_params = params;
512    }
513
514    /// Ensure the per-parameter state matches a model with `n` parameters:
515    /// `constraints` resets to all-`Free` when its length differs, and a stale
516    /// `initial_params` (wrong length) is dropped (silx clears the parameter
517    /// table on theory change). Returns `true` when every constraint is `Free`
518    /// (the unconstrained default, so the fit can take the byte-identical path).
519    fn ensure_constraints_len(&mut self, n: usize) -> bool {
520        if self.constraints.len() != n {
521            self.constraints = vec![Constraint::Free; n];
522        }
523        if self.initial_params.as_ref().is_some_and(|p| p.len() != n) {
524            self.initial_params = None;
525        }
526        self.constraints.iter().all(|c| *c == Constraint::Free)
527    }
528
529    /// The most recent iterative-fit result (covariance / chi-square), if the
530    /// last successful fit used an iterative peak model.
531    pub fn iterative_result(&self) -> Option<&IterativeFitResult> {
532        self.iterative_result.as_ref()
533    }
534
535    /// Is the window currently open?
536    pub fn is_open(&self) -> bool {
537        self.open
538    }
539
540    /// Open or close the window.
541    pub fn set_open(&mut self, open: bool) {
542        self.open = open;
543    }
544
545    /// Set the data to fit.
546    pub fn set_data(&mut self, x: &[f64], y: &[f64]) {
547        self.x_data = x.to_vec();
548        self.y_data = y.to_vec();
549
550        let curve = CurveData::new(self.x_data.clone(), self.y_data.clone(), Color32::BLUE);
551        if let Some(handle) = self.data_handle {
552            self.plot.update_curve_data(handle, &curve);
553        } else {
554            self.data_handle = Some(self.plot.add_curve_with_legend(
555                &self.x_data,
556                &self.y_data,
557                Color32::BLUE,
558                "Data",
559            ));
560        }
561
562        // Clear previous fit
563        if let Some(handle) = self.fit_handle {
564            self.plot.remove(handle);
565            self.fit_handle = None;
566        }
567        self.fit_result = None;
568        self.iterative_result = None;
569        self.initial_params = None;
570        self.plot.reset_zoom_to_data();
571    }
572
573    /// Restrict the data to the configured fit range, if any. Returns owned
574    /// `(xs, ys)` of the in-range points (silx `FitWidget` xmin/xmax).
575    fn ranged_data(&self) -> (Vec<f64>, Vec<f64>) {
576        match self.fit_range {
577            Some((xmin, xmax)) => {
578                let (lo, hi) = if xmin <= xmax {
579                    (xmin, xmax)
580                } else {
581                    (xmax, xmin)
582                };
583                let mut xs = Vec::new();
584                let mut ys = Vec::new();
585                for (&xi, &yi) in self.x_data.iter().zip(self.y_data.iter()) {
586                    if xi >= lo && xi <= hi {
587                        xs.push(xi);
588                        ys.push(yi);
589                    }
590                }
591                (xs, ys)
592            }
593            None => (self.x_data.clone(), self.y_data.clone()),
594        }
595    }
596
597    /// Perform the fit using the currently selected [`FitModelChoice`].
598    ///
599    /// Iterative peak models are refined with Levenberg-Marquardt and populate
600    /// the results table (per-parameter error + reduced chi-square); the
601    /// analytical Linear / Gaussian-estimate choices keep their original
602    /// behaviour. Honors the configured fit range.
603    pub fn perform_fit_choice(&mut self) {
604        if self.x_data.is_empty() || self.y_data.is_empty() {
605            return;
606        }
607        let (xs, ys) = self.ranged_data();
608        // The fit curve is drawn over the in-range points so the displayed fit
609        // matches what was fitted.
610        let result: Option<FitResult> = match self.selected_choice {
611            FitModelChoice::Linear => {
612                self.iterative_result = None;
613                LinearFit.fit(&xs, &ys)
614            }
615            FitModelChoice::GaussianEstimate => {
616                self.iterative_result = None;
617                GaussianEstimateFit.fit(&xs, &ys)
618            }
619            FitModelChoice::MultiGaussian => {
620                // Auto peak-search multi-Gaussian (silx `sum_gauss` theory):
621                // seed the search width from the data (`guess_fwhm`) and fit all
622                // located peaks simultaneously. The background combo does not
623                // apply — the multi-gaussian model carries no per-peak constant
624                // and silx's `StripBackgroundFlag` is off by default.
625                match fit_multi_gaussian_full(
626                    &xs,
627                    &ys,
628                    guess_fwhm(&ys),
629                    DEFAULT_PEAK_SENSITIVITY,
630                    DEFAULT_MAX_ITER,
631                    DEFAULT_DELTACHI,
632                ) {
633                    Some(ir) => {
634                        let fit = ir.fit.clone();
635                        self.iterative_result = Some(ir);
636                        Some(fit)
637                    }
638                    None => {
639                        self.iterative_result = None;
640                        None
641                    }
642                }
643            }
644            choice => {
645                // One of the iterative peak models.
646                let peak_model = choice
647                    .peak_model()
648                    .expect("non-analytical choice has a peak model");
649                match self.background {
650                    // No background: start from edited initial parameters and/or
651                    // apply per-parameter constraints when set, else the original
652                    // unconstrained estimate→fit path (byte-identical).
653                    Background::None => {
654                        let all_free = self.ensure_constraints_len(peak_model.param_names().len());
655                        let fitted = match (&self.initial_params, all_free) {
656                            // Default: no edited start, no constraints.
657                            (None, true) => IterativeFit::new(peak_model).fit_full(&xs, &ys),
658                            // Edited initial parameters → start the fit from them.
659                            (Some(p0), _) => fit_peak_from(
660                                peak_model,
661                                &xs,
662                                &ys,
663                                p0,
664                                &self.constraints,
665                                DEFAULT_MAX_ITER,
666                                DEFAULT_DELTACHI,
667                            ),
668                            // Constraints only → estimate then constrained fit.
669                            (None, false) => fit_peak_constrained(
670                                peak_model,
671                                &xs,
672                                &ys,
673                                &self.constraints,
674                                DEFAULT_MAX_ITER,
675                                DEFAULT_DELTACHI,
676                            ),
677                        };
678                        match fitted {
679                            Some(ir) => {
680                                let fit = ir.fit.clone();
681                                // Populate the editable value column with the
682                                // fitted parameters (silx: the table shows the
683                                // last fit; a re-fit starts from these).
684                                self.initial_params = Some(fit.parameters.clone());
685                                self.iterative_result = Some(ir);
686                                Some(fit)
687                            }
688                            None => {
689                                self.iterative_result = None;
690                                None
691                            }
692                        }
693                    }
694                    // Background theory selected: fit the peak on the
695                    // background-subtracted residual and draw the reconstructed
696                    // total curve, keeping the peak's solver diagnostics for the
697                    // results table (silx background-then-peak workflow).
698                    bg => match fit_peak_with_background(
699                        peak_model,
700                        bg,
701                        &xs,
702                        &ys,
703                        DEFAULT_MAX_ITER,
704                        DEFAULT_DELTACHI,
705                    ) {
706                        Some(bp) => {
707                            let mut fit = bp.peak.fit.clone();
708                            fit.y_fit = bp.total;
709                            self.iterative_result = Some(bp.peak);
710                            Some(fit)
711                        }
712                        None => {
713                            self.iterative_result = None;
714                            None
715                        }
716                    },
717                }
718            }
719        };
720
721        match result {
722            Some(result) => {
723                let curve = CurveData::new(xs.clone(), result.y_fit.clone(), Color32::RED);
724                if let Some(handle) = self.fit_handle {
725                    self.plot.update_curve_data(handle, &curve);
726                } else {
727                    self.fit_handle = Some(self.plot.add_curve_with_legend(
728                        &xs,
729                        &result.y_fit,
730                        Color32::RED,
731                        "Fit",
732                    ));
733                }
734                self.fit_result = Some(result);
735            }
736            None => {
737                self.fit_result = None;
738                self.iterative_result = None;
739                if let Some(handle) = self.fit_handle {
740                    self.plot.remove(handle);
741                    self.fit_handle = None;
742                }
743            }
744        }
745    }
746
747    /// Perform the fit using the currently selected function.
748    pub fn perform_fit(&mut self) {
749        if self.x_data.is_empty() || self.y_data.is_empty() {
750            return;
751        }
752
753        let functions: [&dyn FitFunction; 2] = [&LinearFit, &GaussianEstimateFit];
754        let func = functions[self.selected_function_idx];
755
756        if let Some(result) = func.fit(&self.x_data, &self.y_data) {
757            let curve = CurveData::new(self.x_data.clone(), result.y_fit.clone(), Color32::RED);
758            if let Some(handle) = self.fit_handle {
759                self.plot.update_curve_data(handle, &curve);
760            } else {
761                self.fit_handle = Some(self.plot.add_curve_with_legend(
762                    &self.x_data,
763                    &result.y_fit,
764                    Color32::RED,
765                    "Fit",
766                ));
767            }
768            self.fit_result = Some(result);
769        } else {
770            // Fit failed
771            self.fit_result = None;
772            if let Some(handle) = self.fit_handle {
773                self.plot.remove(handle);
774                self.fit_handle = None;
775            }
776        }
777    }
778
779    /// Show the fit widget using the given egui context.
780    pub fn show(&mut self, ctx: &egui::Context) {
781        if !self.open {
782            return;
783        }
784        let pos = self.win.position(ctx);
785        let id = self.win.id();
786        let size = self.win.size();
787        let signals =
788            crate::widget::detached::show_detached(ctx, id, "Fit Widget", size, pos, |ui| {
789                ui.horizontal(|ui| {
790                    ui.label("Fit Function:");
791                    egui::ComboBox::from_id_salt("fit_function_combo")
792                        .selected_text(self.selected_choice.label())
793                        .show_ui(ui, |ui| {
794                            for choice in FitModelChoice::ALL {
795                                ui.selectable_value(
796                                    &mut self.selected_choice,
797                                    choice,
798                                    choice.label(),
799                                );
800                            }
801                        });
802
803                    if ui.button("Fit").clicked() {
804                        self.perform_fit_choice();
805                    }
806                });
807
808                // Background theory (silx `FitWidget` background combo). Applies
809                // to the iterative peak models; the analytical Linear /
810                // Gaussian-estimate choices ignore it.
811                ui.horizontal(|ui| {
812                    ui.label("Background:");
813                    egui::ComboBox::from_id_salt("fit_background_combo")
814                        .selected_text(background_label(self.background))
815                        .show_ui(ui, |ui| {
816                            for (bg, label) in BACKGROUND_CHOICES {
817                                ui.selectable_value(&mut self.background, bg, label);
818                            }
819                        });
820                });
821
822                // Fit-range selection (silx `FitWidget` xmin/xmax): the checkbox
823                // toggles whole-curve vs a restricted `[xmin, xmax]` window, and
824                // the two DragValues edit the bounds (consumed by
825                // `in_range_points` on the next fit). Enabling defaults the window
826                // to the data's x extent.
827                ui.horizontal(|ui| {
828                    let mut limited = self.fit_range.is_some();
829                    if ui
830                        .checkbox(&mut limited, "Fit range")
831                        .on_hover_text("Restrict the fit to an x window (silx xmin/xmax)")
832                        .changed()
833                    {
834                        self.fit_range = limited.then(|| self.default_fit_range());
835                    }
836                    if let Some((xmin, xmax)) = self.fit_range.as_mut() {
837                        ui.label("min");
838                        ui.add(egui::DragValue::new(xmin).speed(0.1));
839                        ui.label("max");
840                        ui.add(egui::DragValue::new(xmax).speed(0.1));
841                    }
842                });
843
844                // Per-parameter table (silx `FitWidget`), shown for the
845                // single-peak iterative models: an editable initial-value column
846                // (populated after the first fit; the next fit starts from it)
847                // plus a constraint cell — a code combo (`UI_CONSTRAINT_KINDS`)
848                // and the payload editor for the selected code (QUOTED min/max,
849                // FACTOR/DELTA/SUM reference picker + value), all driving
850                // `core::fitting::leastsq_constrained` (silx `Parameters`
851                // Constraints column + cons1/cons2).
852                if let Some(peak_model) = self.selected_choice.peak_model() {
853                    let names = peak_model.param_names();
854                    self.ensure_constraints_len(names.len());
855                    ui.collapsing("Parameters", |ui| {
856                        egui::Grid::new("fit_params_input_grid")
857                            .num_columns(3)
858                            .show(ui, |ui| {
859                                ui.label("Parameter");
860                                ui.label("Initial");
861                                ui.label("Constraint");
862                                ui.end_row();
863                                for (i, name) in names.iter().enumerate() {
864                                    ui.label(name);
865                                    match self.initial_params.as_mut() {
866                                        Some(p0) => {
867                                            ui.add(egui::DragValue::new(&mut p0[i]).speed(0.1));
868                                        }
869                                        None => {
870                                            ui.label("—");
871                                        }
872                                    }
873                                    ui.horizontal(|ui| {
874                                        let current = constraint_kind(self.constraints[i]);
875                                        let mut kind = current;
876                                        egui::ComboBox::from_id_salt(("fit_constraint_combo", i))
877                                            .selected_text(constraint_kind_label(kind))
878                                            .show_ui(ui, |ui| {
879                                                for choice in UI_CONSTRAINT_KINDS {
880                                                    ui.selectable_value(
881                                                        &mut kind,
882                                                        choice,
883                                                        constraint_kind_label(choice),
884                                                    );
885                                                }
886                                            });
887                                        if kind != current {
888                                            // silx rejects FACTOR/DELTA/SUM when no
889                                            // related parameter exists; leave it.
890                                            if let Some(c) =
891                                                make_constraint(kind, i, &self.constraints)
892                                            {
893                                                self.constraints[i] = c;
894                                            }
895                                        }
896                                        // Reference-picker candidates (snapshot
897                                        // before the &mut borrow below).
898                                        let tieable: Vec<bool> = self
899                                            .constraints
900                                            .iter()
901                                            .enumerate()
902                                            .map(|(j, c)| j != i && !is_tied(*c))
903                                            .collect();
904                                        match &mut self.constraints[i] {
905                                            Constraint::Quoted { min, max } => {
906                                                ui.label("min");
907                                                ui.add(egui::DragValue::new(min).speed(0.1));
908                                                ui.label("max");
909                                                ui.add(egui::DragValue::new(max).speed(0.1));
910                                            }
911                                            Constraint::Factor { reference, factor } => {
912                                                reference_param_combo(
913                                                    ui, i, reference, &names, &tieable,
914                                                );
915                                                ui.label("×");
916                                                ui.add(egui::DragValue::new(factor).speed(0.1));
917                                            }
918                                            Constraint::Delta { reference, delta } => {
919                                                reference_param_combo(
920                                                    ui, i, reference, &names, &tieable,
921                                                );
922                                                ui.label("+");
923                                                ui.add(egui::DragValue::new(delta).speed(0.1));
924                                            }
925                                            Constraint::Sum { reference, sum } => {
926                                                reference_param_combo(
927                                                    ui, i, reference, &names, &tieable,
928                                                );
929                                                ui.label("Σ−");
930                                                ui.add(egui::DragValue::new(sum).speed(0.1));
931                                            }
932                                            _ => {}
933                                        }
934                                    });
935                                    ui.end_row();
936                                }
937                            });
938                    });
939                }
940
941                ui.separator();
942
943                // Show fit parameters if available. Iterative fits add a per
944                // parameter estimated error column and a reduced chi-square row
945                // (silx FitWidget results table).
946                if let Some(result) = &self.fit_result {
947                    let errors: Option<Vec<f64>> =
948                        self.iterative_result.as_ref().map(|ir| ir.std_errors());
949                    ui.group(|ui| {
950                        ui.heading("Fit Parameters");
951                        egui::Grid::new("fit_params_grid")
952                            .num_columns(3)
953                            .show(ui, |ui| {
954                                ui.label("Parameter");
955                                ui.label("Value");
956                                ui.label("Error");
957                                ui.end_row();
958                                for (i, (name, val)) in result
959                                    .param_names
960                                    .iter()
961                                    .zip(result.parameters.iter())
962                                    .enumerate()
963                                {
964                                    ui.label(name);
965                                    ui.label(format!("{val:.6}"));
966                                    match errors.as_ref().and_then(|e| e.get(i)) {
967                                        Some(&err) if err.is_finite() => {
968                                            ui.label(format!("{err:.6}"));
969                                        }
970                                        _ => {
971                                            ui.label("");
972                                        }
973                                    }
974                                    ui.end_row();
975                                }
976                            });
977                        if let Some(ir) = &self.iterative_result {
978                            ui.separator();
979                            ui.horizontal(|ui| {
980                                ui.label("Reduced chi-square:");
981                                ui.label(format_reduced_chisq(ir.reduced_chisq()));
982                            });
983                        }
984                    });
985                    ui.separator();
986                }
987
988                // Show the plot
989                self.plot.show(ui);
990            });
991        self.win.apply_signals(&signals, &mut self.open);
992    }
993}
994
995#[cfg(test)]
996mod tests {
997    use super::*;
998    use crate::core::fitting::{IterativeFit, LeastSqResult, PeakModel};
999
1000    #[test]
1001    fn format_value_error_with_finite_error() {
1002        assert_eq!(
1003            format_param_value_error(1.234_567_8, 0.012_345_6),
1004            "1.234568 ± 0.012346"
1005        );
1006    }
1007
1008    #[test]
1009    fn format_value_error_with_nonfinite_error_drops_pm() {
1010        let s = format_param_value_error(2.5, f64::NAN);
1011        assert_eq!(s, "2.500000");
1012        assert!(!s.contains('±'));
1013    }
1014
1015    #[test]
1016    fn format_reduced_chisq_some_and_none() {
1017        assert_eq!(format_reduced_chisq(Some(0.5)), "0.500000");
1018        assert_eq!(format_reduced_chisq(None), "N/A");
1019        assert_eq!(format_reduced_chisq(Some(f64::INFINITY)), "N/A");
1020    }
1021
1022    #[test]
1023    fn default_fit_range_uses_finite_x_extent() {
1024        // The seed window is the finite min/max of the data, skipping NaN/inf.
1025        assert_eq!(
1026            default_fit_range_of(&[3.0, 1.0, f64::NAN, 5.0, f64::INFINITY]),
1027            (1.0, 5.0)
1028        );
1029        // No finite sample -> the (0, 1) fallback.
1030        assert_eq!(default_fit_range_of(&[]), (0.0, 1.0));
1031        assert_eq!(default_fit_range_of(&[f64::NAN]), (0.0, 1.0));
1032    }
1033
1034    #[test]
1035    fn error_extraction_from_covariance_diagonal() {
1036        // The results table errors come from sqrt(diag(covariance)).
1037        let res = LeastSqResult {
1038            parameters: vec![1.0, 2.0],
1039            covariance: vec![vec![9.0, 0.0], vec![0.0, 25.0]],
1040            uncertainties: vec![3.0, 5.0],
1041            chisq: 0.0,
1042            reduced_chisq: Some(0.0),
1043            niter: 1,
1044            nfev: 1,
1045        };
1046        let errs = res.std_errors();
1047        assert!((errs[0] - 3.0).abs() < 1e-12);
1048        assert!((errs[1] - 5.0).abs() < 1e-12);
1049        // And formatting them.
1050        assert_eq!(
1051            format_param_value_error(res.parameters[0], errs[0]),
1052            "1.000000 ± 3.000000"
1053        );
1054    }
1055
1056    #[test]
1057    fn peak_model_mapping_for_iterative_choices() {
1058        assert_eq!(
1059            FitModelChoice::IterativeGaussian.peak_model(),
1060            Some(PeakModel::Gaussian)
1061        );
1062        assert_eq!(
1063            FitModelChoice::IterativeGaussianArea.peak_model(),
1064            Some(PeakModel::GaussianArea)
1065        );
1066        assert_eq!(
1067            FitModelChoice::IterativeSplitGaussian.peak_model(),
1068            Some(PeakModel::SplitGaussian)
1069        );
1070        assert_eq!(
1071            FitModelChoice::IterativeLorentzian.peak_model(),
1072            Some(PeakModel::Lorentzian)
1073        );
1074        assert_eq!(
1075            FitModelChoice::IterativeLorentzianArea.peak_model(),
1076            Some(PeakModel::LorentzianArea)
1077        );
1078        assert_eq!(
1079            FitModelChoice::IterativeSplitLorentzian.peak_model(),
1080            Some(PeakModel::SplitLorentzian)
1081        );
1082        assert_eq!(
1083            FitModelChoice::IterativePseudoVoigt.peak_model(),
1084            Some(PeakModel::PseudoVoigt)
1085        );
1086        assert_eq!(
1087            FitModelChoice::IterativeAreaPseudoVoigt.peak_model(),
1088            Some(PeakModel::AreaPseudoVoigt)
1089        );
1090        assert_eq!(
1091            FitModelChoice::IterativeSplitPseudoVoigt.peak_model(),
1092            Some(PeakModel::SplitPseudoVoigt)
1093        );
1094        assert_eq!(
1095            FitModelChoice::IterativeSplitPseudoVoigt2.peak_model(),
1096            Some(PeakModel::SplitPseudoVoigt2)
1097        );
1098        assert_eq!(
1099            FitModelChoice::IterativeHypermet.peak_model(),
1100            Some(PeakModel::Hypermet)
1101        );
1102        assert_eq!(
1103            FitModelChoice::IterativePolynomial2.peak_model(),
1104            Some(PeakModel::Polynomial2)
1105        );
1106        assert_eq!(
1107            FitModelChoice::IterativePolynomial5.peak_model(),
1108            Some(PeakModel::Polynomial5)
1109        );
1110        assert_eq!(FitModelChoice::Linear.peak_model(), None);
1111        assert_eq!(FitModelChoice::GaussianEstimate.peak_model(), None);
1112    }
1113
1114    #[test]
1115    fn all_choices_listed_once_in_order() {
1116        assert_eq!(FitModelChoice::ALL.len(), 22);
1117        assert_eq!(FitModelChoice::ALL[0], FitModelChoice::Linear);
1118        assert_eq!(FitModelChoice::ALL[8], FitModelChoice::IterativePseudoVoigt);
1119        assert_eq!(FitModelChoice::ALL[15], FitModelChoice::IterativeAtanStepUp);
1120        assert_eq!(FitModelChoice::ALL[16], FitModelChoice::IterativeHypermet);
1121        assert_eq!(
1122            FitModelChoice::ALL[17],
1123            FitModelChoice::IterativePolynomial2
1124        );
1125        assert_eq!(FitModelChoice::ALL[21], FitModelChoice::MultiGaussian);
1126        // Only the single-peak iterative choices map to one `PeakModel`; the
1127        // analytical (Linear / Gaussian-estimate) and composite (multi-peak)
1128        // choices have none.
1129        for choice in FitModelChoice::ALL {
1130            let single_peak = !matches!(
1131                choice,
1132                FitModelChoice::Linear
1133                    | FitModelChoice::GaussianEstimate
1134                    | FitModelChoice::MultiGaussian
1135            );
1136            assert_eq!(choice.peak_model().is_some(), single_peak);
1137        }
1138    }
1139
1140    #[test]
1141    fn background_choices_match_silx_theory_order() {
1142        // silx `bgtheories.THEORY` insertion order.
1143        let labels: Vec<&str> = BACKGROUND_CHOICES.iter().map(|(_, l)| *l).collect();
1144        assert_eq!(
1145            labels,
1146            vec![
1147                "No Background",
1148                "Constant",
1149                "Linear",
1150                "Strip",
1151                "Snip",
1152                "Degree 2 Polynomial",
1153                "Degree 3 Polynomial",
1154                "Degree 4 Polynomial",
1155                "Degree 5 Polynomial",
1156            ]
1157        );
1158        // The first entry is the no-background default.
1159        assert_eq!(BACKGROUND_CHOICES[0].0, Background::None);
1160    }
1161
1162    #[test]
1163    fn background_label_resolves_choices_and_falls_back() {
1164        // Each canonical choice round-trips to its silx label.
1165        for (bg, label) in BACKGROUND_CHOICES {
1166            assert_eq!(background_label(bg), label);
1167        }
1168        // A non-default parameterisation falls back to the generic name.
1169        let custom = Background::Polynomial { degree: 9 };
1170        assert_eq!(background_label(custom), custom.name());
1171    }
1172
1173    #[test]
1174    fn constraint_labels_match_silx_code_options() {
1175        // silx `Parameters.code_options` display strings.
1176        assert_eq!(constraint_kind_label(ConstraintKind::Free), "FREE");
1177        assert_eq!(constraint_kind_label(ConstraintKind::Positive), "POSITIVE");
1178        assert_eq!(constraint_kind_label(ConstraintKind::Quoted), "QUOTED");
1179        assert_eq!(constraint_kind_label(ConstraintKind::Fixed), "FIXED");
1180        assert_eq!(constraint_kind_label(ConstraintKind::Factor), "FACTOR");
1181        assert_eq!(constraint_kind_label(ConstraintKind::Delta), "DELTA");
1182        assert_eq!(constraint_kind_label(ConstraintKind::Sum), "SUM");
1183        assert_eq!(constraint_kind_label(ConstraintKind::Ignore), "IGNORE");
1184        // The combo exposes silx `code_options` minus the group-management
1185        // `ADD`/`SHOW` and the group-only `IGNORE`.
1186        assert_eq!(
1187            UI_CONSTRAINT_KINDS,
1188            [
1189                ConstraintKind::Free,
1190                ConstraintKind::Positive,
1191                ConstraintKind::Quoted,
1192                ConstraintKind::Fixed,
1193                ConstraintKind::Factor,
1194                ConstraintKind::Delta,
1195                ConstraintKind::Sum,
1196            ]
1197        );
1198    }
1199
1200    #[test]
1201    fn constraint_kind_drops_payload() {
1202        assert_eq!(
1203            constraint_kind(Constraint::Quoted { min: 2.0, max: 9.0 }),
1204            ConstraintKind::Quoted
1205        );
1206        assert_eq!(
1207            constraint_kind(Constraint::Factor {
1208                reference: 3,
1209                factor: 0.5
1210            }),
1211            ConstraintKind::Factor
1212        );
1213        assert_eq!(constraint_kind(Constraint::Ignored), ConstraintKind::Ignore);
1214    }
1215
1216    #[test]
1217    fn make_constraint_seeds_silx_defaults_for_payload_codes() {
1218        // FREE/POSITIVE/FIXED need no related parameter and no reference.
1219        let solo = [Constraint::Free];
1220        assert_eq!(
1221            make_constraint(ConstraintKind::Positive, 0, &solo),
1222            Some(Constraint::Positive)
1223        );
1224        assert_eq!(
1225            make_constraint(ConstraintKind::Fixed, 0, &solo),
1226            Some(Constraint::Fixed)
1227        );
1228        // QUOTED seeds the [0, 1] placeholder interval.
1229        assert_eq!(
1230            make_constraint(ConstraintKind::Quoted, 0, &solo),
1231            Some(Constraint::Quoted { min: 0.0, max: 1.0 })
1232        );
1233        // FACTOR/DELTA/SUM tie to the first other free parameter, seeded 1/0/0.
1234        let three = [Constraint::Free, Constraint::Free, Constraint::Free];
1235        assert_eq!(
1236            make_constraint(ConstraintKind::Factor, 1, &three),
1237            Some(Constraint::Factor {
1238                reference: 0,
1239                factor: 1.0
1240            })
1241        );
1242        assert_eq!(
1243            make_constraint(ConstraintKind::Delta, 0, &three),
1244            Some(Constraint::Delta {
1245                reference: 1,
1246                delta: 0.0
1247            })
1248        );
1249        assert_eq!(
1250            make_constraint(ConstraintKind::Sum, 0, &three),
1251            Some(Constraint::Sum {
1252                reference: 1,
1253                sum: 0.0
1254            })
1255        );
1256    }
1257
1258    #[test]
1259    fn make_constraint_rejects_tie_with_no_candidate() {
1260        // A single parameter has no other to tie to (silx returns False).
1261        let solo = [Constraint::Free];
1262        assert_eq!(make_constraint(ConstraintKind::Factor, 0, &solo), None);
1263        assert_eq!(make_constraint(ConstraintKind::Delta, 0, &solo), None);
1264        assert_eq!(make_constraint(ConstraintKind::Sum, 0, &solo), None);
1265    }
1266
1267    #[test]
1268    fn related_reference_skips_self_and_tied_parameters() {
1269        // param 0: only candidate is param 2 (param 1 is itself a tie, excluded).
1270        let constraints = [
1271            Constraint::Free,
1272            Constraint::Factor {
1273                reference: 2,
1274                factor: 1.0,
1275            },
1276            Constraint::Positive,
1277        ];
1278        assert_eq!(default_related_reference(0, &constraints), Some(2));
1279        // No untied other parameter -> None (matches make_constraint rejection).
1280        let all_tied = [
1281            Constraint::Free,
1282            Constraint::Ignored,
1283            Constraint::Sum {
1284                reference: 0,
1285                sum: 1.0,
1286            },
1287        ];
1288        assert_eq!(default_related_reference(1, &all_tied), Some(0));
1289        assert_eq!(default_related_reference(0, &[Constraint::Free]), None);
1290    }
1291
1292    #[test]
1293    fn iterative_fit_result_table_has_one_error_per_param() {
1294        // A clean gaussian; the per-parameter error vector must line up with
1295        // the parameter vector so the results table renders one error per row.
1296        let xs: Vec<f64> = (0..201).map(|i| i as f64 * 0.1).collect();
1297        let ys = crate::core::fitting::gaussian_model(&xs, &[5.0, 10.0, 2.0, 0.5]);
1298        let ir = IterativeFit::new(PeakModel::Gaussian)
1299            .fit_full(&xs, &ys)
1300            .unwrap();
1301        assert_eq!(ir.fit.parameters.len(), ir.std_errors().len());
1302        assert_eq!(ir.fit.param_names.len(), ir.fit.parameters.len());
1303    }
1304}