1use nereids_core::constants::{LM_DIAGONAL_FLOOR, PIVOT_FLOOR};
14
15use crate::error::FittingError;
16use crate::parameters::ParameterSet;
17
18#[derive(Debug, Clone)]
23pub struct FlatMatrix {
24 pub data: Vec<f64>,
26 pub nrows: usize,
28 pub ncols: usize,
30}
31
32impl FlatMatrix {
33 pub fn zeros(nrows: usize, ncols: usize) -> Self {
35 let len = nrows
36 .checked_mul(ncols)
37 .expect("FlatMatrix dimensions overflow usize");
38 Self {
39 data: vec![0.0; len],
40 nrows,
41 ncols,
42 }
43 }
44
45 #[inline(always)]
47 pub fn get(&self, row: usize, col: usize) -> f64 {
48 debug_assert!(row < self.nrows && col < self.ncols);
49 self.data[row * self.ncols + col]
50 }
51
52 #[inline(always)]
54 pub fn get_mut(&mut self, row: usize, col: usize) -> &mut f64 {
55 debug_assert!(row < self.nrows && col < self.ncols);
56 &mut self.data[row * self.ncols + col]
57 }
58}
59
60const LAMBDA_BREAKOUT: f64 = 1e16;
65
66#[derive(Debug, Clone)]
68pub struct LmConfig {
69 pub max_iter: usize,
71 pub lambda_init: f64,
73 pub lambda_up: f64,
75 pub lambda_down: f64,
77 pub tol_chi2: f64,
79 pub tol_param: f64,
81 pub fd_step: f64,
83 pub compute_covariance: bool,
90}
91
92impl Default for LmConfig {
93 fn default() -> Self {
94 Self {
95 max_iter: 200,
96 lambda_init: 1e-3,
97 lambda_up: 10.0,
98 lambda_down: 0.1,
99 tol_chi2: 1e-8,
100 tol_param: 1e-8,
101 fd_step: 1e-6,
102 compute_covariance: true,
103 }
104 }
105}
106
107#[derive(Debug, Clone)]
109pub struct LmResult {
110 pub chi_squared: f64,
112 pub reduced_chi_squared: f64,
114 pub iterations: usize,
116 pub converged: bool,
118 pub params: Vec<f64>,
120 pub covariance: Option<FlatMatrix>,
122 pub uncertainties: Option<Vec<f64>>,
124}
125
126pub trait FitModel {
131 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError>;
142
143 fn analytical_jacobian(
156 &self,
157 _params: &[f64],
158 _free_param_indices: &[usize],
159 _y_current: &[f64],
160 ) -> Option<FlatMatrix> {
161 None
162 }
163}
164
165impl<M: FitModel + ?Sized> FitModel for &M {
172 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
173 (**self).evaluate(params)
174 }
175
176 fn analytical_jacobian(
177 &self,
178 params: &[f64],
179 free_param_indices: &[usize],
180 y_current: &[f64],
181 ) -> Option<FlatMatrix> {
182 (**self).analytical_jacobian(params, free_param_indices, y_current)
183 }
184}
185
186impl<M: FitModel + ?Sized> FitModel for Box<M> {
191 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
192 (**self).evaluate(params)
193 }
194
195 fn analytical_jacobian(
196 &self,
197 params: &[f64],
198 free_param_indices: &[usize],
199 y_current: &[f64],
200 ) -> Option<FlatMatrix> {
201 (**self).analytical_jacobian(params, free_param_indices, y_current)
202 }
203}
204
205fn chi_squared(residuals: &[f64], weights: &[f64], active_mask: Option<&[bool]>) -> f64 {
215 let mut sum = 0.0;
216 for (i, (&r, &w)) in residuals.iter().zip(weights.iter()).enumerate() {
217 if active_mask.is_some_and(|m| !m[i]) {
218 continue;
219 }
220 sum += r * r * w;
221 }
222 sum
223}
224
225fn scaled_gradient_inf_norm(jtw_j: &FlatMatrix, jtw_r: &[f64], chi2: f64) -> f64 {
231 let residual_scale = chi2.sqrt().max(1.0);
232 let mut max_scaled: f64 = 0.0;
233 for (j, &grad_j) in jtw_r.iter().enumerate() {
234 let curvature = jtw_j.get(j, j).abs().sqrt();
235 let scale = curvature * residual_scale + PIVOT_FLOOR;
236 max_scaled = max_scaled.max(grad_j.abs() / scale);
237 }
238 max_scaled
239}
240
241fn has_informative_curvature(jtw_j: &FlatMatrix) -> bool {
246 (0..jtw_j.nrows).any(|j| jtw_j.get(j, j) > LM_DIAGONAL_FLOOR)
247}
248
249fn compute_jacobian(
263 model: &dyn FitModel,
264 params: &mut ParameterSet,
265 y_current: &[f64],
266 fd_step: f64,
267 all_vals_buf: &mut Vec<f64>,
268 free_idx_buf: &mut Vec<usize>,
269) -> Result<FlatMatrix, FittingError> {
270 params.free_indices_into(free_idx_buf);
271 let n_free = free_idx_buf.len();
272 let n_data = y_current.len();
273
274 params.all_values_into(all_vals_buf);
276 if let Some(j) = model.analytical_jacobian(all_vals_buf, free_idx_buf, y_current) {
277 debug_assert!(
278 j.nrows == n_data && j.ncols == n_free && j.data.len() == n_data * n_free,
279 "analytical_jacobian shape mismatch: got ({}x{}, len={}), expected ({}x{}, len={})",
280 j.nrows,
281 j.ncols,
282 j.data.len(),
283 n_data,
284 n_free,
285 n_data * n_free,
286 );
287 return Ok(j);
288 }
289
290 let mut jacobian = FlatMatrix::zeros(n_data, n_free);
292
293 for (j, &idx) in free_idx_buf.iter().enumerate() {
294 let original = params.params[idx].value;
295 let step = fd_step * (1.0 + original.abs());
296
297 params.params[idx].value = original + step;
298 params.params[idx].clamp();
299 let mut actual_step = params.params[idx].value - original;
300
301 if actual_step.abs() < PIVOT_FLOOR {
304 params.params[idx].value = original - step;
305 params.params[idx].clamp();
306 actual_step = params.params[idx].value - original;
307 if actual_step.abs() < PIVOT_FLOOR {
308 params.params[idx].value = original;
310 continue;
311 }
312 }
313
314 params.all_values_into(all_vals_buf);
315 let perturbed = match model.evaluate(all_vals_buf) {
316 Ok(v) => v,
317 Err(_) => {
318 params.params[idx].value = original;
322 continue;
323 }
324 };
325 params.params[idx].value = original;
326
327 for i in 0..n_data {
345 let p = perturbed[i];
346 let y = y_current[i];
347 if p.is_finite() && y.is_finite() {
348 *jacobian.get_mut(i, j) = (p - y) / actual_step;
349 }
350 }
354 }
355
356 Ok(jacobian)
357}
358
359pub(crate) fn solve_damped_system(a: &FlatMatrix, b: &[f64], lambda: f64) -> Option<Vec<f64>> {
364 let n = b.len();
365 if n == 0 {
366 return Some(vec![]);
367 }
368
369 let ncols = n + 1;
371 let mut aug = FlatMatrix::zeros(n, ncols);
372 for (i, &bi) in b.iter().enumerate() {
373 for j in 0..n {
374 *aug.get_mut(i, j) = a.get(i, j);
375 }
376 *aug.get_mut(i, i) += lambda * a.get(i, i).max(LM_DIAGONAL_FLOOR); *aug.get_mut(i, n) = bi;
378 }
379
380 for col in 0..n {
382 let mut max_val = aug.get(col, col).abs();
384 let mut max_row = col;
385 for row in (col + 1)..n {
386 if aug.get(row, col).abs() > max_val {
387 max_val = aug.get(row, col).abs();
388 max_row = row;
389 }
390 }
391
392 if max_val < PIVOT_FLOOR {
393 return None; }
395
396 if col != max_row {
398 let (row_a, row_b) = (col * ncols, max_row * ncols);
399 let (first, second) = aug.data.split_at_mut(row_b);
400 first[row_a..row_a + ncols].swap_with_slice(&mut second[..ncols]);
401 }
402
403 let pivot = aug.get(col, col);
404 for row in (col + 1)..n {
405 let factor = aug.get(row, col) / pivot;
406 for j in col..=n {
407 let val = aug.get(col, j);
408 *aug.get_mut(row, j) -= factor * val;
409 }
410 }
411 }
412
413 let mut x = vec![0.0; n];
415 for i in (0..n).rev() {
416 let mut sum = aug.get(i, n);
417 for (j, &xj) in x.iter().enumerate().skip(i + 1) {
418 sum -= aug.get(i, j) * xj;
419 }
420 x[i] = sum / aug.get(i, i);
421 }
422
423 Some(x)
424}
425
426pub(crate) fn invert_matrix(a: &FlatMatrix) -> Option<FlatMatrix> {
430 let n = a.nrows;
431 if n == 0 {
432 return Some(FlatMatrix::zeros(0, 0));
433 }
434
435 let ncols = 2 * n;
437 let mut aug = FlatMatrix::zeros(n, ncols);
438 for i in 0..n {
439 for j in 0..n {
440 *aug.get_mut(i, j) = a.get(i, j);
441 }
442 *aug.get_mut(i, n + i) = 1.0;
443 }
444
445 for col in 0..n {
447 let mut max_val = aug.get(col, col).abs();
448 let mut max_row = col;
449 for row in (col + 1)..n {
450 if aug.get(row, col).abs() > max_val {
451 max_val = aug.get(row, col).abs();
452 max_row = row;
453 }
454 }
455
456 if max_val < PIVOT_FLOOR {
457 return None;
458 }
459
460 if col != max_row {
462 let (row_a, row_b) = (col * ncols, max_row * ncols);
463 let (first, second) = aug.data.split_at_mut(row_b);
464 first[row_a..row_a + ncols].swap_with_slice(&mut second[..ncols]);
465 }
466
467 let pivot = aug.get(col, col);
468 for j in 0..ncols {
469 *aug.get_mut(col, j) /= pivot;
470 }
471
472 for row in 0..n {
473 if row != col {
474 let factor = aug.get(row, col);
475 for j in 0..ncols {
476 let val = aug.get(col, j);
477 *aug.get_mut(row, j) -= factor * val;
478 }
479 }
480 }
481 }
482
483 let mut inv = FlatMatrix::zeros(n, n);
485 for i in 0..n {
486 for j in 0..n {
487 *inv.get_mut(i, j) = aug.get(i, n + j);
488 }
489 }
490
491 Some(inv)
492}
493
494pub fn levenberg_marquardt(
506 model: &dyn FitModel,
507 y_obs: &[f64],
508 sigma: &[f64],
509 params: &mut ParameterSet,
510 config: &LmConfig,
511) -> Result<LmResult, FittingError> {
512 levenberg_marquardt_with_mask(model, y_obs, sigma, params, config, None)
513}
514
515pub fn levenberg_marquardt_with_mask(
530 model: &dyn FitModel,
531 y_obs: &[f64],
532 sigma: &[f64],
533 params: &mut ParameterSet,
534 config: &LmConfig,
535 active_mask: Option<&[bool]>,
536) -> Result<LmResult, FittingError> {
537 let n_data = y_obs.len();
538 if n_data == 0 {
539 return Err(FittingError::EmptyData);
540 }
541 if sigma.len() != n_data {
542 return Err(FittingError::LengthMismatch {
543 expected: n_data,
544 actual: sigma.len(),
545 field: "sigma",
546 });
547 }
548 if let Some(m) = active_mask
549 && m.len() != n_data
550 {
551 return Err(FittingError::LengthMismatch {
552 expected: n_data,
553 actual: m.len(),
554 field: "active_mask",
555 });
556 }
557 let n_active = crate::active_mask::active_count(active_mask, n_data);
558
559 if n_active == 0 {
568 return Ok(LmResult {
569 chi_squared: f64::NAN,
570 reduced_chi_squared: f64::NAN,
571 iterations: 0,
572 converged: false,
573 params: params.all_values(),
574 covariance: None,
575 uncertainties: None,
576 });
577 }
578
579 let n_free = params.n_free();
580
581 if n_free == 0 {
585 let weights: Vec<f64> = sigma
586 .iter()
587 .enumerate()
588 .map(|(i, &s)| {
589 if active_mask.is_some_and(|m| !m[i]) {
592 return 0.0;
593 }
594 if !s.is_finite() || s <= 0.0 {
595 1.0 / 1e30
596 } else {
597 1.0 / (s * s)
598 }
599 })
600 .collect();
601 let y_model = model.evaluate(¶ms.all_values())?;
602
603 let model_has_active_nonfinite = y_model
613 .iter()
614 .enumerate()
615 .any(|(i, v)| active_mask.is_none_or(|m| m[i]) && !v.is_finite());
616 if model_has_active_nonfinite {
617 return Ok(LmResult {
618 chi_squared: f64::NAN,
619 reduced_chi_squared: f64::NAN,
620 iterations: 0,
621 converged: false,
622 params: params.all_values(),
623 covariance: None,
624 uncertainties: None,
625 });
626 }
627
628 let residuals: Vec<f64> = y_obs
629 .iter()
630 .zip(y_model.iter())
631 .map(|(&obs, &mdl)| obs - mdl)
632 .collect();
633 let chi2 = chi_squared(&residuals, &weights, active_mask);
634 let dof = n_active.saturating_sub(n_free);
640 let reduced = if dof > 0 { chi2 / dof as f64 } else { f64::NAN };
641 return Ok(LmResult {
642 chi_squared: chi2,
643 reduced_chi_squared: reduced,
644 iterations: 0,
645 converged: true,
646 params: params.all_values(),
647 covariance: Some(FlatMatrix::zeros(0, 0)),
648 uncertainties: Some(vec![]),
649 });
650 }
651
652 if n_active < n_free {
662 return Ok(LmResult {
663 chi_squared: f64::NAN,
664 reduced_chi_squared: f64::NAN,
665 iterations: 0,
666 converged: false,
667 params: params.all_values(),
668 covariance: None,
669 uncertainties: None,
670 });
671 }
672 let dof = n_active - n_free;
673
674 let weights: Vec<f64> = sigma
685 .iter()
686 .enumerate()
687 .map(|(i, &s)| {
688 if active_mask.is_some_and(|m| !m[i]) {
689 return 0.0;
690 }
691 if !s.is_finite() || s <= 0.0 {
692 1.0 / 1e30
694 } else {
695 1.0 / (s * s)
696 }
697 })
698 .collect();
699
700 let mut all_vals_buf = Vec::with_capacity(params.params.len());
707 let mut free_vals_buf = Vec::with_capacity(n_free);
708 let mut free_idx_buf = Vec::with_capacity(n_free);
709
710 params.all_values_into(&mut all_vals_buf);
714 let mut y_current = model.evaluate(&all_vals_buf)?;
715 let mut residuals: Vec<f64> = y_obs
716 .iter()
717 .zip(y_current.iter())
718 .map(|(&obs, &mdl)| obs - mdl)
719 .collect();
720 let mut chi2 = chi_squared(&residuals, &weights, active_mask);
721
722 let mut lambda = config.lambda_init;
723 let mut converged = false;
724 let mut iter = 0;
725
726 for _ in 0..config.max_iter {
727 iter += 1;
728
729 let jacobian = compute_jacobian(
733 model,
734 params,
735 &y_current,
736 config.fd_step,
737 &mut all_vals_buf,
738 &mut free_idx_buf,
739 )?;
740
741 let mut jtw_j = FlatMatrix::zeros(n_free, n_free);
749 let mut jtw_r = vec![0.0; n_free];
750
751 for (i, (&wi, &ri)) in weights.iter().zip(residuals.iter()).enumerate() {
752 if active_mask.is_some_and(|m| !m[i]) {
753 continue;
754 }
755 for (j, jtw_r_j) in jtw_r.iter_mut().enumerate() {
756 let jij = jacobian.get(i, j);
757 *jtw_r_j += jij * wi * ri;
758 for k in 0..n_free {
759 *jtw_j.get_mut(j, k) += jij * wi * jacobian.get(i, k);
760 }
761 }
762 }
763 let scaled_grad_inf = scaled_gradient_inf_norm(&jtw_j, &jtw_r, chi2);
764 let informative_curvature = has_informative_curvature(&jtw_j);
765
766 let delta = match solve_damped_system(&jtw_j, &jtw_r, lambda) {
768 Some(d) => d,
769 None => break, };
771
772 params.free_values_into(&mut free_vals_buf);
775 let trial_free: Vec<f64> = free_vals_buf
776 .iter()
777 .zip(delta.iter())
778 .map(|(&v, &d)| v + d)
779 .collect();
780 let param_change: f64 = delta
781 .iter()
782 .zip(free_vals_buf.iter())
783 .map(|(&d, &v)| (d / (v.abs() + PIVOT_FLOOR)).powi(2))
784 .sum::<f64>()
785 .sqrt();
786 params.set_free_values(&trial_free);
787
788 params.all_values_into(&mut all_vals_buf);
789 let y_trial = match model.evaluate(&all_vals_buf) {
790 Ok(y) => y,
791 Err(_) => {
792 params.set_free_values(&free_vals_buf);
794 lambda *= config.lambda_up;
795 if lambda > LAMBDA_BREAKOUT {
796 converged = false;
797 break;
798 }
799 continue;
800 }
801 };
802
803 let trial_has_active_nonfinite = y_trial
809 .iter()
810 .enumerate()
811 .any(|(i, v)| active_mask.is_none_or(|m| m[i]) && !v.is_finite());
812 if trial_has_active_nonfinite {
813 params.set_free_values(&free_vals_buf);
814 lambda *= config.lambda_up;
815 if lambda > LAMBDA_BREAKOUT {
816 converged = false;
817 break;
818 }
819 continue;
820 }
821
822 let trial_residuals: Vec<f64> = y_obs
823 .iter()
824 .zip(y_trial.iter())
825 .map(|(&obs, &mdl)| obs - mdl)
826 .collect();
827 let trial_chi2 = chi_squared(&trial_residuals, &weights, active_mask);
828 let chi2_delta = (trial_chi2 - chi2).abs();
829 let chi2_scale = chi2.abs().max(trial_chi2.abs()).max(1.0);
830 let chi2_stagnated = chi2_delta <= config.tol_chi2 * chi2_scale;
831
832 if trial_chi2 < chi2 {
833 let rel_change = (chi2 - trial_chi2) / (chi2 + PIVOT_FLOOR);
836 chi2 = trial_chi2;
837 residuals = trial_residuals;
838 y_current = y_trial;
839 lambda *= config.lambda_down;
840
841 if rel_change < config.tol_chi2 || param_change < config.tol_param {
846 converged = true;
847 break;
848 }
849 } else {
850 let grad_tol = config.tol_chi2.sqrt().max(config.tol_param.sqrt());
858 if chi2_stagnated
859 && param_change < config.tol_param
860 && scaled_grad_inf < grad_tol
861 && informative_curvature
862 {
863 params.set_free_values(&free_vals_buf);
864 converged = true;
865 break;
866 }
867
868 params.set_free_values(&free_vals_buf);
871 lambda *= config.lambda_up;
872
873 if lambda > LAMBDA_BREAKOUT {
877 converged = false;
878 break;
879 }
880 }
881 }
882
883 let reduced_chi2 = if dof > 0 { chi2 / dof as f64 } else { f64::NAN };
884
885 let (covariance, uncertainties) = if converged && config.compute_covariance {
892 let jacobian = compute_jacobian(
893 model,
894 params,
895 &y_current,
896 config.fd_step,
897 &mut all_vals_buf,
898 &mut free_idx_buf,
899 )?;
900 let mut jtw_j = FlatMatrix::zeros(n_free, n_free);
904 for (i, &wi) in weights.iter().enumerate() {
905 if active_mask.is_some_and(|m| !m[i]) {
906 continue;
907 }
908 for j in 0..n_free {
909 let jij = jacobian.get(i, j);
910 for k in 0..n_free {
911 *jtw_j.get_mut(j, k) += jij * wi * jacobian.get(i, k);
912 }
913 }
914 }
915
916 if dof > 0 {
927 if let Some(mut cov) = invert_matrix(&jtw_j) {
928 for elem in cov.data.iter_mut() {
929 *elem *= reduced_chi2;
930 }
931 let unc: Vec<f64> = (0..n_free)
932 .map(|i| {
933 let diag = cov.get(i, i);
934 if diag.is_finite() && diag > 0.0 {
935 diag.sqrt()
936 } else {
937 f64::NAN
938 }
939 })
940 .collect();
941 (Some(cov), Some(unc))
942 } else {
943 (None, None)
944 }
945 } else {
946 (None, None)
948 }
949 } else {
950 (None, None)
952 };
953
954 Ok(LmResult {
955 chi_squared: chi2,
956 reduced_chi_squared: reduced_chi2,
957 iterations: iter,
958 converged,
959 params: params.all_values(),
960 covariance,
961 uncertainties,
962 })
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968 use crate::parameters::{FitParameter, ParameterSet};
969
970 struct LinearModel {
972 x: Vec<f64>,
973 }
974
975 impl FitModel for LinearModel {
976 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
977 let a = params[0];
978 let b = params[1];
979 Ok(self.x.iter().map(|&x| a * x + b).collect())
980 }
981 }
982
983 #[test]
984 fn test_fit_linear_exact() {
985 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
987 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
988 let sigma = vec![1.0; 10];
989
990 let model = LinearModel { x };
991 let mut params = ParameterSet::new(vec![
992 FitParameter::unbounded("a", 1.0), FitParameter::unbounded("b", 1.0),
994 ]);
995
996 let result =
997 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
998
999 assert!(result.converged, "Fit did not converge");
1000 assert!(
1001 (result.params[0] - 2.0).abs() < 1e-4,
1002 "a = {}, expected 2.0",
1003 result.params[0]
1004 );
1005 assert!(
1006 (result.params[1] - 3.0).abs() < 1e-4,
1007 "b = {}, expected 3.0",
1008 result.params[1]
1009 );
1010 assert!(result.chi_squared < 1e-6);
1011 }
1012
1013 #[test]
1014 fn test_converges_on_exact_flat_bottom_without_lambda_breakout() {
1015 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1020 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1021 let sigma = vec![1.0; 10];
1022
1023 let model = LinearModel { x };
1024 let mut params = ParameterSet::new(vec![
1025 FitParameter::unbounded("a", 0.0),
1026 FitParameter::unbounded("b", 0.0),
1027 ]);
1028 let config = LmConfig {
1029 lambda_init: 0.0,
1030 ..LmConfig::default()
1031 };
1032
1033 let result = levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
1034
1035 assert!(
1036 result.converged,
1037 "Fit should converge on an exact flat bottom"
1038 );
1039 assert!(result.chi_squared < 1e-20, "chi2 = {}", result.chi_squared);
1040 assert!(
1041 result.iterations < config.max_iter,
1042 "LM should stop by convergence, not by iteration exhaustion"
1043 );
1044 }
1045
1046 #[test]
1047 fn test_converges_on_nonzero_chi2_stationary_point() {
1048 struct AffineModel {
1053 x: Vec<f64>,
1054 }
1055 impl FitModel for AffineModel {
1056 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1057 let a = params[0];
1058 let b = params[1];
1059 Ok(self.x.iter().map(|&x| a * x + b).collect())
1060 }
1061 }
1062
1063 let model = AffineModel {
1064 x: vec![0.0, 1.0, 2.0, 3.0, 4.0],
1065 };
1066 let y_obs = vec![0.1, 0.9, 2.2, 2.8, 4.1];
1067 let sigma = vec![1.0; y_obs.len()];
1068 let mut params = ParameterSet::new(vec![
1069 FitParameter::unbounded("a", 0.0),
1070 FitParameter::unbounded("b", 0.0),
1071 ]);
1072 let config = LmConfig {
1073 max_iter: 200,
1074 tol_chi2: 1e-16,
1075 tol_param: 1e-16,
1076 ..LmConfig::default()
1077 };
1078
1079 let result = levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
1080
1081 assert!(
1082 result.converged,
1083 "stationary nonzero-chi2 optimum should converge instead of lambda breakout"
1084 );
1085 assert!(
1086 result.reduced_chi_squared.is_finite() && result.reduced_chi_squared > 0.0,
1087 "expected nonzero reduced chi2 at noisy optimum, got {}",
1088 result.reduced_chi_squared
1089 );
1090 }
1091
1092 #[test]
1093 fn test_fit_linear_with_fixed_param() {
1094 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1096 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1097 let sigma = vec![1.0; 10];
1098
1099 let model = LinearModel { x };
1100 let mut params = ParameterSet::new(vec![
1101 FitParameter::unbounded("a", 1.0),
1102 FitParameter::fixed("b", 3.0),
1103 ]);
1104
1105 let result =
1106 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1107
1108 assert!(result.converged);
1109 assert!(
1110 (result.params[0] - 2.0).abs() < 1e-6,
1111 "a = {}",
1112 result.params[0]
1113 );
1114 assert_eq!(result.params[1], 3.0); }
1116
1117 #[test]
1118 fn test_fit_quadratic() {
1119 struct QuadModel {
1121 x: Vec<f64>,
1122 }
1123 impl FitModel for QuadModel {
1124 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1125 let (a, b, c) = (params[0], params[1], params[2]);
1126 Ok(self.x.iter().map(|&x| a * x * x + b * x + c).collect())
1127 }
1128 }
1129
1130 let x: Vec<f64> = (0..20).map(|i| i as f64 * 0.5).collect();
1131 let y_obs: Vec<f64> = x.iter().map(|&xi| 0.5 * xi * xi - 2.0 * xi + 1.0).collect();
1132 let sigma = vec![1.0; 20];
1133
1134 let model = QuadModel { x };
1135 let mut params = ParameterSet::new(vec![
1136 FitParameter::unbounded("a", 1.0),
1137 FitParameter::unbounded("b", 0.0),
1138 FitParameter::unbounded("c", 0.0),
1139 ]);
1140
1141 let result =
1142 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1143
1144 assert!(result.converged);
1145 assert!(
1146 (result.params[0] - 0.5).abs() < 1e-5,
1147 "a = {}",
1148 result.params[0]
1149 );
1150 assert!(
1151 (result.params[1] - (-2.0)).abs() < 1e-5,
1152 "b = {}",
1153 result.params[1]
1154 );
1155 assert!(
1156 (result.params[2] - 1.0).abs() < 1e-5,
1157 "c = {}",
1158 result.params[2]
1159 );
1160 }
1161
1162 #[test]
1163 fn test_non_negative_constraint() {
1164 struct SlopeModel {
1168 x: Vec<f64>,
1169 }
1170 impl FitModel for SlopeModel {
1171 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1172 let a = params[0];
1173 Ok(self.x.iter().map(|&x| a * x).collect())
1174 }
1175 }
1176
1177 let x: Vec<f64> = (1..10).map(|i| i as f64).collect();
1178 let y_obs: Vec<f64> = x.iter().map(|&xi| -2.0 * xi).collect();
1179 let sigma = vec![1.0; 9];
1180
1181 let model = SlopeModel { x };
1182 let mut params = ParameterSet::new(vec![FitParameter::non_negative("a", 1.0)]);
1183
1184 let result =
1185 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1186
1187 assert!(
1189 result.params[0] >= 0.0 && result.params[0] < 0.1,
1190 "a = {}, expected ~0",
1191 result.params[0]
1192 );
1193 }
1194
1195 #[test]
1196 fn test_uncertainty_estimation() {
1197 let x: Vec<f64> = (0..100).map(|i| i as f64 * 0.1).collect();
1199 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1200 let sigma = vec![0.1; 100]; let model = LinearModel { x };
1203 let mut params = ParameterSet::new(vec![
1204 FitParameter::unbounded("a", 1.0),
1205 FitParameter::unbounded("b", 1.0),
1206 ]);
1207
1208 let result =
1209 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1210
1211 assert!(result.converged);
1212 assert!(result.uncertainties.is_some());
1213 let unc = result.uncertainties.unwrap();
1214 assert!(unc[0] > 0.0 && unc[0] < 0.01, "σ_a = {}", unc[0]);
1216 assert!(unc[1] > 0.0 && unc[1] < 0.1, "σ_b = {}", unc[1]);
1217 }
1218
1219 #[test]
1220 fn test_solve_damped_system_identity() {
1221 let a = FlatMatrix {
1223 data: vec![1.0, 0.0, 0.0, 1.0],
1224 nrows: 2,
1225 ncols: 2,
1226 };
1227 let b = vec![2.0, 4.0];
1228 let lambda = 1.0;
1229 let x = solve_damped_system(&a, &b, lambda).unwrap();
1230 assert!((x[0] - 1.0).abs() < 1e-10);
1231 assert!((x[1] - 2.0).abs() < 1e-10);
1232 }
1233
1234 #[test]
1235 fn test_invert_matrix_2x2() {
1236 let a = FlatMatrix {
1237 data: vec![4.0, 7.0, 2.0, 6.0],
1238 nrows: 2,
1239 ncols: 2,
1240 };
1241 let inv = invert_matrix(&a).unwrap();
1242 assert!((inv.get(0, 0) - 0.6).abs() < 1e-10);
1244 assert!((inv.get(0, 1) - (-0.7)).abs() < 1e-10);
1245 assert!((inv.get(1, 0) - (-0.2)).abs() < 1e-10);
1246 assert!((inv.get(1, 1) - 0.4).abs() < 1e-10);
1247 }
1248
1249 #[test]
1252 fn test_all_fixed_params_nan_model() {
1253 struct NanModel;
1256 impl FitModel for NanModel {
1257 fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
1258 Ok(vec![f64::NAN; 5])
1259 }
1260 }
1261
1262 let y_obs = vec![1.0; 5];
1263 let sigma = vec![1.0; 5];
1264 let mut params = ParameterSet::new(vec![FitParameter::fixed("a", 1.0)]);
1265
1266 let result =
1267 levenberg_marquardt(&NanModel, &y_obs, &sigma, &mut params, &LmConfig::default())
1268 .unwrap();
1269
1270 assert!(!result.converged, "All-fixed NaN model should not converge");
1271 assert!(result.chi_squared.is_nan(), "chi2 should be NaN");
1272 assert_eq!(result.iterations, 0);
1273 }
1274
1275 #[test]
1276 fn test_underdetermined_system() {
1277 let y_obs = vec![1.0, 2.0]; let sigma = vec![1.0, 1.0];
1281
1282 struct ThreeParamModel;
1285 impl FitModel for ThreeParamModel {
1286 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1287 Ok(vec![params[0] + params[1] + params[2]; 2])
1288 }
1289 }
1290
1291 let mut params = ParameterSet::new(vec![
1292 FitParameter::unbounded("a", 1.0),
1293 FitParameter::unbounded("b", 1.0),
1294 FitParameter::unbounded("c", 1.0),
1295 ]);
1296
1297 let result = levenberg_marquardt(
1298 &ThreeParamModel,
1299 &y_obs,
1300 &sigma,
1301 &mut params,
1302 &LmConfig::default(),
1303 )
1304 .unwrap();
1305
1306 assert!(
1307 !result.converged,
1308 "Underdetermined system should not converge"
1309 );
1310 assert!(result.chi_squared.is_nan());
1311 assert_eq!(result.iterations, 0);
1312 }
1313
1314 #[test]
1315 fn test_exactly_determined_dof_zero() {
1316 let y_obs = vec![5.0, 11.0]; let sigma = vec![1.0, 1.0];
1320
1321 let model = LinearModel { x: vec![1.0, 4.0] };
1322 let mut params = ParameterSet::new(vec![
1323 FitParameter::unbounded("a", 1.0),
1324 FitParameter::unbounded("b", 1.0),
1325 ]);
1326
1327 let result =
1328 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1329
1330 assert!(
1331 result.converged,
1332 "Exactly-determined system should converge"
1333 );
1334 assert!(
1335 result.chi_squared < 1e-6,
1336 "chi2 should be ~0, got {}",
1337 result.chi_squared
1338 );
1339 assert!(
1341 result.reduced_chi_squared.is_nan(),
1342 "reduced_chi2 should be NaN for dof=0, got {}",
1343 result.reduced_chi_squared
1344 );
1345 assert!(result.covariance.is_none());
1347 assert!(result.uncertainties.is_none());
1348 }
1349
1350 #[test]
1351 fn test_lambda_breakout() {
1352 struct ConstantModel;
1354 impl FitModel for ConstantModel {
1355 fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
1356 Ok(vec![42.0; 5])
1359 }
1360 }
1361
1362 let y_obs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1363 let sigma = vec![1.0; 5];
1364 let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 1.0)]);
1365
1366 let config = LmConfig {
1367 max_iter: 1000,
1368 ..LmConfig::default()
1369 };
1370
1371 let result =
1372 levenberg_marquardt(&ConstantModel, &y_obs, &sigma, &mut params, &config).unwrap();
1373
1374 assert!(
1375 !result.converged,
1376 "Flat model should not converge (lambda breakout)"
1377 );
1378 assert!(
1379 result.covariance.is_none(),
1380 "unconverged fit should not report covariance"
1381 );
1382 assert!(
1383 result.uncertainties.is_none(),
1384 "unconverged fit should not report uncertainties"
1385 );
1386 }
1387
1388 #[test]
1389 fn test_nan_model_during_iteration() {
1390 struct NanAtLargeModel {
1393 x: Vec<f64>,
1394 }
1395 impl FitModel for NanAtLargeModel {
1396 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1397 let a = params[0];
1398 Ok(self
1399 .x
1400 .iter()
1401 .map(|&x| if a > 5.0 { f64::NAN } else { a * x + 1.0 })
1402 .collect())
1403 }
1404 }
1405
1406 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1407 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1408 let sigma = vec![1.0; 10];
1409
1410 let model = NanAtLargeModel { x };
1411 let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 3.0)]);
1412
1413 let result =
1414 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1415
1416 assert!(result.converged, "Should converge avoiding NaN region");
1418 assert!(
1419 (result.params[0] - 2.0).abs() < 0.1,
1420 "a = {}, expected ~2.0",
1421 result.params[0]
1422 );
1423 }
1424
1425 #[test]
1426 fn test_err_model_during_trial_step() {
1427 struct ErrAtLargeModel {
1431 x: Vec<f64>,
1432 }
1433 impl FitModel for ErrAtLargeModel {
1434 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1435 let a = params[0];
1436 if a > 5.0 {
1437 return Err(FittingError::EvaluationFailed(
1438 "parameter out of valid range".into(),
1439 ));
1440 }
1441 Ok(self.x.iter().map(|&x| a * x + 1.0).collect())
1442 }
1443 }
1444
1445 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1446 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1447 let sigma = vec![1.0; 10];
1448
1449 let model = ErrAtLargeModel { x };
1450 let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 3.0)]);
1451
1452 let result =
1453 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1454
1455 assert!(result.converged, "Should converge avoiding Err region");
1457 assert!(
1458 (result.params[0] - 2.0).abs() < 0.1,
1459 "a = {}, expected ~2.0",
1460 result.params[0]
1461 );
1462 }
1463
1464 #[test]
1465 fn test_fit_linear_no_covariance() {
1466 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1469 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1470 let sigma = vec![1.0; 10];
1471
1472 let model = LinearModel { x };
1473 let mut params = ParameterSet::new(vec![
1474 FitParameter::unbounded("a", 1.0),
1475 FitParameter::unbounded("b", 1.0),
1476 ]);
1477
1478 let config = LmConfig {
1479 compute_covariance: false,
1480 ..LmConfig::default()
1481 };
1482
1483 let result = levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
1484
1485 assert!(result.converged, "Fit did not converge");
1486 assert!(
1487 (result.params[0] - 2.0).abs() < 1e-4,
1488 "a = {}, expected 2.0",
1489 result.params[0]
1490 );
1491 assert!(
1492 (result.params[1] - 3.0).abs() < 1e-4,
1493 "b = {}, expected 3.0",
1494 result.params[1]
1495 );
1496 assert!(result.chi_squared < 1e-6);
1497 assert!(
1498 result.covariance.is_none(),
1499 "covariance should be None when compute_covariance=false"
1500 );
1501 assert!(
1502 result.uncertainties.is_none(),
1503 "uncertainties should be None when compute_covariance=false"
1504 );
1505 }
1506
1507 #[test]
1508 fn test_zero_negative_sigma_clamping() {
1509 let y_obs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1512 let sigma = vec![0.0, -1.0, f64::NAN, f64::INFINITY, 1.0];
1513
1514 let model = LinearModel {
1515 x: vec![0.0, 1.0, 2.0, 3.0, 4.0],
1516 };
1517 let mut params = ParameterSet::new(vec![
1518 FitParameter::unbounded("a", 1.0),
1519 FitParameter::unbounded("b", 0.0),
1520 ]);
1521
1522 let result =
1524 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1525
1526 assert!(
1527 result.chi_squared.is_finite(),
1528 "chi2 should be finite despite bad sigma, got {}",
1529 result.chi_squared
1530 );
1531 assert!(
1532 result.converged,
1533 "Fit should converge despite bad sigma values"
1534 );
1535 let y_at_4 = result.params[0] * 4.0 + result.params[1];
1538 assert!(
1539 (y_at_4 - 5.0).abs() < 1.0,
1540 "Fitted line should pass near (4, 5): a={}, b={}, y(4)={}",
1541 result.params[0],
1542 result.params[1],
1543 y_at_4,
1544 );
1545 }
1546
1547 #[test]
1556 fn test_lm_active_mask_subset_equivalence() {
1557 let x_full: Vec<f64> = (0..10).map(|i| i as f64).collect();
1559 let y_full: Vec<f64> = x_full.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1560 let sigma_full = vec![1.0; 10];
1561 let mask = vec![
1562 true, true, true, true, true, false, false, false, false, false,
1563 ];
1564
1565 let model = LinearModel { x: x_full.clone() };
1566 let mut params = ParameterSet::new(vec![
1567 FitParameter::unbounded("a", 1.0),
1568 FitParameter::unbounded("b", 1.0),
1569 ]);
1570 let result_masked = levenberg_marquardt_with_mask(
1571 &model,
1572 &y_full,
1573 &sigma_full,
1574 &mut params,
1575 &LmConfig::default(),
1576 Some(&mask),
1577 )
1578 .unwrap();
1579 assert!(result_masked.converged);
1580 assert!((result_masked.params[0] - 2.0).abs() < 1e-6);
1581 assert!((result_masked.params[1] - 3.0).abs() < 1e-6);
1582
1583 let x_sub = x_full[..5].to_vec();
1585 let y_sub = y_full[..5].to_vec();
1586 let sigma_sub = vec![1.0; 5];
1587 let model_sub = LinearModel { x: x_sub };
1588 let mut params_sub = ParameterSet::new(vec![
1589 FitParameter::unbounded("a", 1.0),
1590 FitParameter::unbounded("b", 1.0),
1591 ]);
1592 let result_sub = levenberg_marquardt(
1593 &model_sub,
1594 &y_sub,
1595 &sigma_sub,
1596 &mut params_sub,
1597 &LmConfig::default(),
1598 )
1599 .unwrap();
1600
1601 for j in 0..2 {
1604 assert!(
1605 (result_masked.params[j] - result_sub.params[j]).abs() < 1e-6,
1606 "param {j}: masked={} subset={}",
1607 result_masked.params[j],
1608 result_sub.params[j]
1609 );
1610 }
1611 assert!(
1612 (result_masked.reduced_chi_squared - result_sub.reduced_chi_squared).abs() < 1e-6,
1613 "reduced-χ²: masked={} subset={}",
1614 result_masked.reduced_chi_squared,
1615 result_sub.reduced_chi_squared
1616 );
1617 }
1618
1619 #[test]
1623 fn test_lm_active_mask_excludes_out_of_range_residuals() {
1624 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1626 let mut y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1627 for yi in y.iter_mut().skip(5) {
1628 *yi += 1000.0; }
1630 let sigma = vec![1.0; 10];
1631 let mask = vec![true; 5]
1632 .into_iter()
1633 .chain(std::iter::repeat_n(false, 5))
1634 .collect::<Vec<bool>>();
1635
1636 let model = LinearModel { x };
1637 let mut params = ParameterSet::new(vec![
1638 FitParameter::unbounded("a", 1.0),
1639 FitParameter::unbounded("b", 1.0),
1640 ]);
1641 let result = levenberg_marquardt_with_mask(
1642 &model,
1643 &y,
1644 &sigma,
1645 &mut params,
1646 &LmConfig::default(),
1647 Some(&mask),
1648 )
1649 .unwrap();
1650 assert!(result.converged);
1651 assert!(
1652 (result.params[0] - 2.0).abs() < 1e-6,
1653 "slope should be 2.0 (corrupt outliers must be masked out), got {}",
1654 result.params[0]
1655 );
1656 assert!(
1657 (result.params[1] - 3.0).abs() < 1e-6,
1658 "intercept should be 3.0 (corrupt outliers must be masked out), got {}",
1659 result.params[1]
1660 );
1661 }
1662
1663 #[test]
1668 fn test_lm_active_mask_tolerates_nan_outside_range() {
1669 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1673 let mut y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1674 for yi in y.iter_mut().skip(5) {
1675 *yi = f64::NAN;
1676 }
1677 let sigma = vec![1.0; 10];
1678 let mask = vec![true; 5]
1679 .into_iter()
1680 .chain(std::iter::repeat_n(false, 5))
1681 .collect::<Vec<bool>>();
1682
1683 let model = LinearModel { x };
1684 let mut params = ParameterSet::new(vec![
1685 FitParameter::unbounded("a", 1.0),
1686 FitParameter::unbounded("b", 1.0),
1687 ]);
1688 let result = levenberg_marquardt_with_mask(
1689 &model,
1690 &y,
1691 &sigma,
1692 &mut params,
1693 &LmConfig::default(),
1694 Some(&mask),
1695 )
1696 .unwrap();
1697
1698 assert!(
1699 result.converged,
1700 "fit should converge despite NaN outside the active mask"
1701 );
1702 assert!(
1703 result.chi_squared.is_finite(),
1704 "χ² should be finite (NaN poisoning prevented), got {}",
1705 result.chi_squared
1706 );
1707 assert!(
1708 (result.params[0] - 2.0).abs() < 1e-6,
1709 "slope should be 2.0, got {}",
1710 result.params[0]
1711 );
1712 assert!(
1713 (result.params[1] - 3.0).abs() < 1e-6,
1714 "intercept should be 3.0, got {}",
1715 result.params[1]
1716 );
1717 }
1718
1719 #[test]
1723 fn test_lm_active_mask_tolerates_model_nan_outside_range() {
1724 struct LinearModelWithMaskedNaN {
1728 x: Vec<f64>,
1729 mask: Vec<bool>,
1730 }
1731 impl FitModel for LinearModelWithMaskedNaN {
1732 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1733 let a = params[0];
1734 let b = params[1];
1735 Ok(self
1736 .x
1737 .iter()
1738 .enumerate()
1739 .map(|(i, &x)| if self.mask[i] { a * x + b } else { f64::NAN })
1740 .collect())
1741 }
1742 }
1743
1744 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1745 let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1746 let sigma = vec![1.0; 10];
1747 let mask: Vec<bool> = vec![true; 5]
1748 .into_iter()
1749 .chain(std::iter::repeat_n(false, 5))
1750 .collect();
1751
1752 let model = LinearModelWithMaskedNaN {
1753 x: x.clone(),
1754 mask: mask.clone(),
1755 };
1756 let mut params = ParameterSet::new(vec![
1757 FitParameter::unbounded("a", 1.0),
1758 FitParameter::unbounded("b", 1.0),
1759 ]);
1760 let result = levenberg_marquardt_with_mask(
1761 &model,
1762 &y,
1763 &sigma,
1764 &mut params,
1765 &LmConfig::default(),
1766 Some(&mask),
1767 )
1768 .unwrap();
1769
1770 assert!(
1771 result.converged,
1772 "fit should converge despite model NaN at masked bins"
1773 );
1774 assert!(result.chi_squared.is_finite());
1775 assert!((result.params[0] - 2.0).abs() < 1e-6);
1776 assert!((result.params[1] - 3.0).abs() < 1e-6);
1777 }
1778
1779 #[test]
1785 fn test_lm_active_mask_all_false_returns_non_converged() {
1786 let x: Vec<f64> = (0..5).map(|i| i as f64).collect();
1787 let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1788 let sigma = vec![1.0; 5];
1789 let mask = vec![false; 5];
1790 let model = LinearModel { x: x.clone() };
1791
1792 let mut params_free = ParameterSet::new(vec![
1794 FitParameter::unbounded("a", 1.0),
1795 FitParameter::unbounded("b", 1.0),
1796 ]);
1797 let r_free = levenberg_marquardt_with_mask(
1798 &model,
1799 &y,
1800 &sigma,
1801 &mut params_free,
1802 &LmConfig::default(),
1803 Some(&mask),
1804 )
1805 .unwrap();
1806 assert!(
1807 !r_free.converged,
1808 "n_free > 0 + zero-active mask must NOT report converged"
1809 );
1810 assert!(r_free.chi_squared.is_nan());
1811 assert!(r_free.reduced_chi_squared.is_nan());
1812
1813 let mut params_fixed = ParameterSet::new(vec![
1816 FitParameter::fixed("a", 1.0),
1817 FitParameter::fixed("b", 0.0),
1818 ]);
1819 let r_fixed = levenberg_marquardt_with_mask(
1820 &model,
1821 &y,
1822 &sigma,
1823 &mut params_fixed,
1824 &LmConfig::default(),
1825 Some(&mask),
1826 )
1827 .unwrap();
1828 assert!(
1829 !r_fixed.converged,
1830 "n_free == 0 + zero-active mask must NOT report converged \
1831 (sum over zero rows would be 0, masquerading as a perfect fit)"
1832 );
1833 assert!(r_fixed.chi_squared.is_nan());
1834 assert!(r_fixed.reduced_chi_squared.is_nan());
1835 }
1836
1837 #[test]
1861 fn test_compute_jacobian_skips_nan_perturbed_column() {
1862 use crate::parameters::FitParameter;
1863 struct NanInColumn1 {
1868 p1_base: f64,
1870 }
1871 impl FitModel for NanInColumn1 {
1872 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1873 let nan_row = (params[1] - self.p1_base).abs() > 1e-12;
1876 let v = if nan_row { f64::NAN } else { params[0] };
1877 Ok(vec![v; 4])
1878 }
1879 }
1881 let model = NanInColumn1 { p1_base: 0.3 };
1882 let mut params = ParameterSet::new(vec![
1883 FitParameter::unbounded("p0", 0.5),
1884 FitParameter::unbounded("p1", 0.3),
1885 ]);
1886 let y_current = vec![0.5; 4];
1887 let mut all_vals_buf: Vec<f64> = Vec::new();
1888 let mut free_idx_buf: Vec<usize> = Vec::new();
1889 let jac = compute_jacobian(
1890 &model,
1891 &mut params,
1892 &y_current,
1893 1e-6,
1894 &mut all_vals_buf,
1895 &mut free_idx_buf,
1896 )
1897 .unwrap();
1898 for (i, v) in jac.data.iter().enumerate() {
1900 assert!(
1901 v.is_finite(),
1902 "compute_jacobian produced non-finite entry at index {i}: {v}"
1903 );
1904 }
1905 for i in 0..jac.nrows {
1908 assert_eq!(
1909 jac.get(i, 1),
1910 0.0,
1911 "column 1 (NaN probe) should be zeroed, row {i}"
1912 );
1913 }
1914 }
1915}