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
19pub 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
33pub 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
43fn 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
62const 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
88fn 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103enum ConstraintKind {
104 Free,
106 Positive,
108 Quoted,
110 Fixed,
112 Factor,
114 Delta,
116 Sum,
118 Ignore,
120}
121
122fn 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
136fn 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
150const 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
166fn is_tied(constraint: Constraint) -> bool {
170 matches!(
171 constraint,
172 Constraint::Factor { .. }
173 | Constraint::Delta { .. }
174 | Constraint::Sum { .. }
175 | Constraint::Ignored
176 )
177}
178
179fn 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
193fn 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
225fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum FitModelChoice {
255 Linear,
257 GaussianEstimate,
259 IterativeGaussian,
261 IterativeGaussianArea,
263 IterativeSplitGaussian,
265 IterativeLorentzian,
267 IterativeLorentzianArea,
269 IterativeSplitLorentzian,
271 IterativePseudoVoigt,
273 IterativeAreaPseudoVoigt,
275 IterativeSplitPseudoVoigt,
277 IterativeSplitPseudoVoigt2,
279 IterativeStepDown,
281 IterativeStepUp,
283 IterativeSlit,
285 IterativeAtanStepUp,
287 IterativeHypermet,
289 IterativePolynomial2,
291 IterativePolynomial3,
293 IterativePolynomial4,
295 IterativePolynomial5,
297 MultiGaussian,
300}
301
302impl FitModelChoice {
303 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 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 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 FitModelChoice::Linear
382 | FitModelChoice::GaussianEstimate
383 | FitModelChoice::MultiGaussian => None,
384 }
385 }
386}
387
388pub 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 x_data: Vec<f64>,
398 y_data: Vec<f64>,
399
400 selected_function_idx: usize,
402 fit_result: Option<FitResult>,
403
404 selected_choice: FitModelChoice,
406 iterative_result: Option<IterativeFitResult>,
407 fit_range: Option<(f64, f64)>,
410 background: Background,
413 constraints: Vec<Constraint>,
417 initial_params: Option<Vec<f64>>,
422}
423
424impl FitWidget {
425 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 fn default_fit_range(&self) -> (f64, f64) {
456 default_fit_range_of(&self.x_data)
457 }
458
459 pub fn set_fit_range(&mut self, range: Option<(f64, f64)>) {
462 self.fit_range = range;
463 }
464
465 pub fn selected_choice(&self) -> FitModelChoice {
467 self.selected_choice
468 }
469
470 pub fn set_selected_choice(&mut self, choice: FitModelChoice) {
472 self.selected_choice = choice;
473 }
474
475 pub fn fit_background(&self) -> Background {
478 self.background
479 }
480
481 pub fn set_fit_background(&mut self, background: Background) {
486 self.background = background;
487 }
488
489 pub fn param_constraints(&self) -> &[Constraint] {
492 &self.constraints
493 }
494
495 pub fn set_param_constraints(&mut self, constraints: Vec<Constraint>) {
499 self.constraints = constraints;
500 }
501
502 pub fn initial_params(&self) -> Option<&[f64]> {
505 self.initial_params.as_deref()
506 }
507
508 pub fn set_initial_params(&mut self, params: Option<Vec<f64>>) {
511 self.initial_params = params;
512 }
513
514 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 pub fn iterative_result(&self) -> Option<&IterativeFitResult> {
532 self.iterative_result.as_ref()
533 }
534
535 pub fn is_open(&self) -> bool {
537 self.open
538 }
539
540 pub fn set_open(&mut self, open: bool) {
542 self.open = open;
543 }
544
545 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 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 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 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 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 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 let peak_model = choice
647 .peak_model()
648 .expect("non-analytical choice has a peak model");
649 match self.background {
650 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 (None, true) => IterativeFit::new(peak_model).fit_full(&xs, &ys),
658 (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 (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 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 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 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 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 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 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 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 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 if let Some(c) =
891 make_constraint(kind, i, &self.constraints)
892 {
893 self.constraints[i] = c;
894 }
895 }
896 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 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 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 assert_eq!(
1026 default_fit_range_of(&[3.0, 1.0, f64::NAN, 5.0, f64::INFINITY]),
1027 (1.0, 5.0)
1028 );
1029 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 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 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 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 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 assert_eq!(BACKGROUND_CHOICES[0].0, Background::None);
1160 }
1161
1162 #[test]
1163 fn background_label_resolves_choices_and_falls_back() {
1164 for (bg, label) in BACKGROUND_CHOICES {
1166 assert_eq!(background_label(bg), label);
1167 }
1168 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 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 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 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 assert_eq!(
1230 make_constraint(ConstraintKind::Quoted, 0, &solo),
1231 Some(Constraint::Quoted { min: 0.0, max: 1.0 })
1232 );
1233 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 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 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 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 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}