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
186fn chi_squared(residuals: &[f64], weights: &[f64], active_mask: Option<&[bool]>) -> f64 {
196 let mut sum = 0.0;
197 for (i, (&r, &w)) in residuals.iter().zip(weights.iter()).enumerate() {
198 if active_mask.is_some_and(|m| !m[i]) {
199 continue;
200 }
201 sum += r * r * w;
202 }
203 sum
204}
205
206fn scaled_gradient_inf_norm(jtw_j: &FlatMatrix, jtw_r: &[f64], chi2: f64) -> f64 {
212 let residual_scale = chi2.sqrt().max(1.0);
213 let mut max_scaled: f64 = 0.0;
214 for (j, &grad_j) in jtw_r.iter().enumerate() {
215 let curvature = jtw_j.get(j, j).abs().sqrt();
216 let scale = curvature * residual_scale + PIVOT_FLOOR;
217 max_scaled = max_scaled.max(grad_j.abs() / scale);
218 }
219 max_scaled
220}
221
222fn has_informative_curvature(jtw_j: &FlatMatrix) -> bool {
227 (0..jtw_j.nrows).any(|j| jtw_j.get(j, j) > LM_DIAGONAL_FLOOR)
228}
229
230fn compute_jacobian(
244 model: &dyn FitModel,
245 params: &mut ParameterSet,
246 y_current: &[f64],
247 fd_step: f64,
248 all_vals_buf: &mut Vec<f64>,
249 free_idx_buf: &mut Vec<usize>,
250) -> Result<FlatMatrix, FittingError> {
251 params.free_indices_into(free_idx_buf);
252 let n_free = free_idx_buf.len();
253 let n_data = y_current.len();
254
255 params.all_values_into(all_vals_buf);
257 if let Some(j) = model.analytical_jacobian(all_vals_buf, free_idx_buf, y_current) {
258 debug_assert!(
259 j.nrows == n_data && j.ncols == n_free && j.data.len() == n_data * n_free,
260 "analytical_jacobian shape mismatch: got ({}x{}, len={}), expected ({}x{}, len={})",
261 j.nrows,
262 j.ncols,
263 j.data.len(),
264 n_data,
265 n_free,
266 n_data * n_free,
267 );
268 return Ok(j);
269 }
270
271 let mut jacobian = FlatMatrix::zeros(n_data, n_free);
273
274 for (j, &idx) in free_idx_buf.iter().enumerate() {
275 let original = params.params[idx].value;
276 let step = fd_step * (1.0 + original.abs());
277
278 params.params[idx].value = original + step;
279 params.params[idx].clamp();
280 let mut actual_step = params.params[idx].value - original;
281
282 if actual_step.abs() < PIVOT_FLOOR {
285 params.params[idx].value = original - step;
286 params.params[idx].clamp();
287 actual_step = params.params[idx].value - original;
288 if actual_step.abs() < PIVOT_FLOOR {
289 params.params[idx].value = original;
291 continue;
292 }
293 }
294
295 params.all_values_into(all_vals_buf);
296 let perturbed = match model.evaluate(all_vals_buf) {
297 Ok(v) => v,
298 Err(_) => {
299 params.params[idx].value = original;
303 continue;
304 }
305 };
306 params.params[idx].value = original;
307
308 for i in 0..n_data {
326 let p = perturbed[i];
327 let y = y_current[i];
328 if p.is_finite() && y.is_finite() {
329 *jacobian.get_mut(i, j) = (p - y) / actual_step;
330 }
331 }
335 }
336
337 Ok(jacobian)
338}
339
340pub(crate) fn solve_damped_system(a: &FlatMatrix, b: &[f64], lambda: f64) -> Option<Vec<f64>> {
345 let n = b.len();
346 if n == 0 {
347 return Some(vec![]);
348 }
349
350 let ncols = n + 1;
352 let mut aug = FlatMatrix::zeros(n, ncols);
353 for (i, &bi) in b.iter().enumerate() {
354 for j in 0..n {
355 *aug.get_mut(i, j) = a.get(i, j);
356 }
357 *aug.get_mut(i, i) += lambda * a.get(i, i).max(LM_DIAGONAL_FLOOR); *aug.get_mut(i, n) = bi;
359 }
360
361 for col in 0..n {
363 let mut max_val = aug.get(col, col).abs();
365 let mut max_row = col;
366 for row in (col + 1)..n {
367 if aug.get(row, col).abs() > max_val {
368 max_val = aug.get(row, col).abs();
369 max_row = row;
370 }
371 }
372
373 if max_val < PIVOT_FLOOR {
374 return None; }
376
377 if col != max_row {
379 let (row_a, row_b) = (col * ncols, max_row * ncols);
380 let (first, second) = aug.data.split_at_mut(row_b);
381 first[row_a..row_a + ncols].swap_with_slice(&mut second[..ncols]);
382 }
383
384 let pivot = aug.get(col, col);
385 for row in (col + 1)..n {
386 let factor = aug.get(row, col) / pivot;
387 for j in col..=n {
388 let val = aug.get(col, j);
389 *aug.get_mut(row, j) -= factor * val;
390 }
391 }
392 }
393
394 let mut x = vec![0.0; n];
396 for i in (0..n).rev() {
397 let mut sum = aug.get(i, n);
398 for (j, &xj) in x.iter().enumerate().skip(i + 1) {
399 sum -= aug.get(i, j) * xj;
400 }
401 x[i] = sum / aug.get(i, i);
402 }
403
404 Some(x)
405}
406
407pub(crate) fn invert_matrix(a: &FlatMatrix) -> Option<FlatMatrix> {
411 let n = a.nrows;
412 if n == 0 {
413 return Some(FlatMatrix::zeros(0, 0));
414 }
415
416 let ncols = 2 * n;
418 let mut aug = FlatMatrix::zeros(n, ncols);
419 for i in 0..n {
420 for j in 0..n {
421 *aug.get_mut(i, j) = a.get(i, j);
422 }
423 *aug.get_mut(i, n + i) = 1.0;
424 }
425
426 for col in 0..n {
428 let mut max_val = aug.get(col, col).abs();
429 let mut max_row = col;
430 for row in (col + 1)..n {
431 if aug.get(row, col).abs() > max_val {
432 max_val = aug.get(row, col).abs();
433 max_row = row;
434 }
435 }
436
437 if max_val < PIVOT_FLOOR {
438 return None;
439 }
440
441 if col != max_row {
443 let (row_a, row_b) = (col * ncols, max_row * ncols);
444 let (first, second) = aug.data.split_at_mut(row_b);
445 first[row_a..row_a + ncols].swap_with_slice(&mut second[..ncols]);
446 }
447
448 let pivot = aug.get(col, col);
449 for j in 0..ncols {
450 *aug.get_mut(col, j) /= pivot;
451 }
452
453 for row in 0..n {
454 if row != col {
455 let factor = aug.get(row, col);
456 for j in 0..ncols {
457 let val = aug.get(col, j);
458 *aug.get_mut(row, j) -= factor * val;
459 }
460 }
461 }
462 }
463
464 let mut inv = FlatMatrix::zeros(n, n);
466 for i in 0..n {
467 for j in 0..n {
468 *inv.get_mut(i, j) = aug.get(i, n + j);
469 }
470 }
471
472 Some(inv)
473}
474
475pub fn levenberg_marquardt(
487 model: &dyn FitModel,
488 y_obs: &[f64],
489 sigma: &[f64],
490 params: &mut ParameterSet,
491 config: &LmConfig,
492) -> Result<LmResult, FittingError> {
493 levenberg_marquardt_with_mask(model, y_obs, sigma, params, config, None)
494}
495
496pub fn levenberg_marquardt_with_mask(
511 model: &dyn FitModel,
512 y_obs: &[f64],
513 sigma: &[f64],
514 params: &mut ParameterSet,
515 config: &LmConfig,
516 active_mask: Option<&[bool]>,
517) -> Result<LmResult, FittingError> {
518 let n_data = y_obs.len();
519 if n_data == 0 {
520 return Err(FittingError::EmptyData);
521 }
522 if sigma.len() != n_data {
523 return Err(FittingError::LengthMismatch {
524 expected: n_data,
525 actual: sigma.len(),
526 field: "sigma",
527 });
528 }
529 if let Some(m) = active_mask
530 && m.len() != n_data
531 {
532 return Err(FittingError::LengthMismatch {
533 expected: n_data,
534 actual: m.len(),
535 field: "active_mask",
536 });
537 }
538 let n_active = crate::active_mask::active_count(active_mask, n_data);
539
540 if n_active == 0 {
549 return Ok(LmResult {
550 chi_squared: f64::NAN,
551 reduced_chi_squared: f64::NAN,
552 iterations: 0,
553 converged: false,
554 params: params.all_values(),
555 covariance: None,
556 uncertainties: None,
557 });
558 }
559
560 let n_free = params.n_free();
561
562 if n_free == 0 {
566 let weights: Vec<f64> = sigma
567 .iter()
568 .enumerate()
569 .map(|(i, &s)| {
570 if active_mask.is_some_and(|m| !m[i]) {
573 return 0.0;
574 }
575 if !s.is_finite() || s <= 0.0 {
576 1.0 / 1e30
577 } else {
578 1.0 / (s * s)
579 }
580 })
581 .collect();
582 let y_model = model.evaluate(¶ms.all_values())?;
583
584 let model_has_active_nonfinite = y_model
594 .iter()
595 .enumerate()
596 .any(|(i, v)| active_mask.is_none_or(|m| m[i]) && !v.is_finite());
597 if model_has_active_nonfinite {
598 return Ok(LmResult {
599 chi_squared: f64::NAN,
600 reduced_chi_squared: f64::NAN,
601 iterations: 0,
602 converged: false,
603 params: params.all_values(),
604 covariance: None,
605 uncertainties: None,
606 });
607 }
608
609 let residuals: Vec<f64> = y_obs
610 .iter()
611 .zip(y_model.iter())
612 .map(|(&obs, &mdl)| obs - mdl)
613 .collect();
614 let chi2 = chi_squared(&residuals, &weights, active_mask);
615 let dof = n_active.saturating_sub(n_free);
621 let reduced = if dof > 0 { chi2 / dof as f64 } else { f64::NAN };
622 return Ok(LmResult {
623 chi_squared: chi2,
624 reduced_chi_squared: reduced,
625 iterations: 0,
626 converged: true,
627 params: params.all_values(),
628 covariance: Some(FlatMatrix::zeros(0, 0)),
629 uncertainties: Some(vec![]),
630 });
631 }
632
633 if n_active < n_free {
643 return Ok(LmResult {
644 chi_squared: f64::NAN,
645 reduced_chi_squared: f64::NAN,
646 iterations: 0,
647 converged: false,
648 params: params.all_values(),
649 covariance: None,
650 uncertainties: None,
651 });
652 }
653 let dof = n_active - n_free;
654
655 let weights: Vec<f64> = sigma
666 .iter()
667 .enumerate()
668 .map(|(i, &s)| {
669 if active_mask.is_some_and(|m| !m[i]) {
670 return 0.0;
671 }
672 if !s.is_finite() || s <= 0.0 {
673 1.0 / 1e30
675 } else {
676 1.0 / (s * s)
677 }
678 })
679 .collect();
680
681 let mut all_vals_buf = Vec::with_capacity(params.params.len());
688 let mut free_vals_buf = Vec::with_capacity(n_free);
689 let mut free_idx_buf = Vec::with_capacity(n_free);
690
691 params.all_values_into(&mut all_vals_buf);
695 let mut y_current = model.evaluate(&all_vals_buf)?;
696 let mut residuals: Vec<f64> = y_obs
697 .iter()
698 .zip(y_current.iter())
699 .map(|(&obs, &mdl)| obs - mdl)
700 .collect();
701 let mut chi2 = chi_squared(&residuals, &weights, active_mask);
702
703 let mut lambda = config.lambda_init;
704 let mut converged = false;
705 let mut iter = 0;
706
707 for _ in 0..config.max_iter {
708 iter += 1;
709
710 let jacobian = compute_jacobian(
714 model,
715 params,
716 &y_current,
717 config.fd_step,
718 &mut all_vals_buf,
719 &mut free_idx_buf,
720 )?;
721
722 let mut jtw_j = FlatMatrix::zeros(n_free, n_free);
730 let mut jtw_r = vec![0.0; n_free];
731
732 for (i, (&wi, &ri)) in weights.iter().zip(residuals.iter()).enumerate() {
733 if active_mask.is_some_and(|m| !m[i]) {
734 continue;
735 }
736 for (j, jtw_r_j) in jtw_r.iter_mut().enumerate() {
737 let jij = jacobian.get(i, j);
738 *jtw_r_j += jij * wi * ri;
739 for k in 0..n_free {
740 *jtw_j.get_mut(j, k) += jij * wi * jacobian.get(i, k);
741 }
742 }
743 }
744 let scaled_grad_inf = scaled_gradient_inf_norm(&jtw_j, &jtw_r, chi2);
745 let informative_curvature = has_informative_curvature(&jtw_j);
746
747 let delta = match solve_damped_system(&jtw_j, &jtw_r, lambda) {
749 Some(d) => d,
750 None => break, };
752
753 params.free_values_into(&mut free_vals_buf);
756 let trial_free: Vec<f64> = free_vals_buf
757 .iter()
758 .zip(delta.iter())
759 .map(|(&v, &d)| v + d)
760 .collect();
761 let param_change: f64 = delta
762 .iter()
763 .zip(free_vals_buf.iter())
764 .map(|(&d, &v)| (d / (v.abs() + PIVOT_FLOOR)).powi(2))
765 .sum::<f64>()
766 .sqrt();
767 params.set_free_values(&trial_free);
768
769 params.all_values_into(&mut all_vals_buf);
770 let y_trial = match model.evaluate(&all_vals_buf) {
771 Ok(y) => y,
772 Err(_) => {
773 params.set_free_values(&free_vals_buf);
775 lambda *= config.lambda_up;
776 if lambda > LAMBDA_BREAKOUT {
777 converged = false;
778 break;
779 }
780 continue;
781 }
782 };
783
784 let trial_has_active_nonfinite = y_trial
790 .iter()
791 .enumerate()
792 .any(|(i, v)| active_mask.is_none_or(|m| m[i]) && !v.is_finite());
793 if trial_has_active_nonfinite {
794 params.set_free_values(&free_vals_buf);
795 lambda *= config.lambda_up;
796 if lambda > LAMBDA_BREAKOUT {
797 converged = false;
798 break;
799 }
800 continue;
801 }
802
803 let trial_residuals: Vec<f64> = y_obs
804 .iter()
805 .zip(y_trial.iter())
806 .map(|(&obs, &mdl)| obs - mdl)
807 .collect();
808 let trial_chi2 = chi_squared(&trial_residuals, &weights, active_mask);
809 let chi2_delta = (trial_chi2 - chi2).abs();
810 let chi2_scale = chi2.abs().max(trial_chi2.abs()).max(1.0);
811 let chi2_stagnated = chi2_delta <= config.tol_chi2 * chi2_scale;
812
813 if trial_chi2 < chi2 {
814 let rel_change = (chi2 - trial_chi2) / (chi2 + PIVOT_FLOOR);
817 chi2 = trial_chi2;
818 residuals = trial_residuals;
819 y_current = y_trial;
820 lambda *= config.lambda_down;
821
822 if rel_change < config.tol_chi2 || param_change < config.tol_param {
827 converged = true;
828 break;
829 }
830 } else {
831 let grad_tol = config.tol_chi2.sqrt().max(config.tol_param.sqrt());
839 if chi2_stagnated
840 && param_change < config.tol_param
841 && scaled_grad_inf < grad_tol
842 && informative_curvature
843 {
844 params.set_free_values(&free_vals_buf);
845 converged = true;
846 break;
847 }
848
849 params.set_free_values(&free_vals_buf);
852 lambda *= config.lambda_up;
853
854 if lambda > LAMBDA_BREAKOUT {
858 converged = false;
859 break;
860 }
861 }
862 }
863
864 let reduced_chi2 = if dof > 0 { chi2 / dof as f64 } else { f64::NAN };
865
866 let (covariance, uncertainties) = if converged && config.compute_covariance {
873 let jacobian = compute_jacobian(
874 model,
875 params,
876 &y_current,
877 config.fd_step,
878 &mut all_vals_buf,
879 &mut free_idx_buf,
880 )?;
881 let mut jtw_j = FlatMatrix::zeros(n_free, n_free);
885 for (i, &wi) in weights.iter().enumerate() {
886 if active_mask.is_some_and(|m| !m[i]) {
887 continue;
888 }
889 for j in 0..n_free {
890 let jij = jacobian.get(i, j);
891 for k in 0..n_free {
892 *jtw_j.get_mut(j, k) += jij * wi * jacobian.get(i, k);
893 }
894 }
895 }
896
897 if dof > 0 {
908 if let Some(mut cov) = invert_matrix(&jtw_j) {
909 for elem in cov.data.iter_mut() {
910 *elem *= reduced_chi2;
911 }
912 let unc: Vec<f64> = (0..n_free)
913 .map(|i| {
914 let diag = cov.get(i, i);
915 if diag.is_finite() && diag > 0.0 {
916 diag.sqrt()
917 } else {
918 f64::NAN
919 }
920 })
921 .collect();
922 (Some(cov), Some(unc))
923 } else {
924 (None, None)
925 }
926 } else {
927 (None, None)
929 }
930 } else {
931 (None, None)
933 };
934
935 Ok(LmResult {
936 chi_squared: chi2,
937 reduced_chi_squared: reduced_chi2,
938 iterations: iter,
939 converged,
940 params: params.all_values(),
941 covariance,
942 uncertainties,
943 })
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949 use crate::parameters::{FitParameter, ParameterSet};
950
951 struct LinearModel {
953 x: Vec<f64>,
954 }
955
956 impl FitModel for LinearModel {
957 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
958 let a = params[0];
959 let b = params[1];
960 Ok(self.x.iter().map(|&x| a * x + b).collect())
961 }
962 }
963
964 #[test]
965 fn test_fit_linear_exact() {
966 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
968 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
969 let sigma = vec![1.0; 10];
970
971 let model = LinearModel { x };
972 let mut params = ParameterSet::new(vec![
973 FitParameter::unbounded("a", 1.0), FitParameter::unbounded("b", 1.0),
975 ]);
976
977 let result =
978 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
979
980 assert!(result.converged, "Fit did not converge");
981 assert!(
982 (result.params[0] - 2.0).abs() < 1e-4,
983 "a = {}, expected 2.0",
984 result.params[0]
985 );
986 assert!(
987 (result.params[1] - 3.0).abs() < 1e-4,
988 "b = {}, expected 3.0",
989 result.params[1]
990 );
991 assert!(result.chi_squared < 1e-6);
992 }
993
994 #[test]
995 fn test_converges_on_exact_flat_bottom_without_lambda_breakout() {
996 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1001 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1002 let sigma = vec![1.0; 10];
1003
1004 let model = LinearModel { x };
1005 let mut params = ParameterSet::new(vec![
1006 FitParameter::unbounded("a", 0.0),
1007 FitParameter::unbounded("b", 0.0),
1008 ]);
1009 let config = LmConfig {
1010 lambda_init: 0.0,
1011 ..LmConfig::default()
1012 };
1013
1014 let result = levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
1015
1016 assert!(
1017 result.converged,
1018 "Fit should converge on an exact flat bottom"
1019 );
1020 assert!(result.chi_squared < 1e-20, "chi2 = {}", result.chi_squared);
1021 assert!(
1022 result.iterations < config.max_iter,
1023 "LM should stop by convergence, not by iteration exhaustion"
1024 );
1025 }
1026
1027 #[test]
1028 fn test_converges_on_nonzero_chi2_stationary_point() {
1029 struct AffineModel {
1034 x: Vec<f64>,
1035 }
1036 impl FitModel for AffineModel {
1037 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1038 let a = params[0];
1039 let b = params[1];
1040 Ok(self.x.iter().map(|&x| a * x + b).collect())
1041 }
1042 }
1043
1044 let model = AffineModel {
1045 x: vec![0.0, 1.0, 2.0, 3.0, 4.0],
1046 };
1047 let y_obs = vec![0.1, 0.9, 2.2, 2.8, 4.1];
1048 let sigma = vec![1.0; y_obs.len()];
1049 let mut params = ParameterSet::new(vec![
1050 FitParameter::unbounded("a", 0.0),
1051 FitParameter::unbounded("b", 0.0),
1052 ]);
1053 let config = LmConfig {
1054 max_iter: 200,
1055 tol_chi2: 1e-16,
1056 tol_param: 1e-16,
1057 ..LmConfig::default()
1058 };
1059
1060 let result = levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
1061
1062 assert!(
1063 result.converged,
1064 "stationary nonzero-chi2 optimum should converge instead of lambda breakout"
1065 );
1066 assert!(
1067 result.reduced_chi_squared.is_finite() && result.reduced_chi_squared > 0.0,
1068 "expected nonzero reduced chi2 at noisy optimum, got {}",
1069 result.reduced_chi_squared
1070 );
1071 }
1072
1073 #[test]
1074 fn test_fit_linear_with_fixed_param() {
1075 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1077 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1078 let sigma = vec![1.0; 10];
1079
1080 let model = LinearModel { x };
1081 let mut params = ParameterSet::new(vec![
1082 FitParameter::unbounded("a", 1.0),
1083 FitParameter::fixed("b", 3.0),
1084 ]);
1085
1086 let result =
1087 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1088
1089 assert!(result.converged);
1090 assert!(
1091 (result.params[0] - 2.0).abs() < 1e-6,
1092 "a = {}",
1093 result.params[0]
1094 );
1095 assert_eq!(result.params[1], 3.0); }
1097
1098 #[test]
1099 fn test_fit_quadratic() {
1100 struct QuadModel {
1102 x: Vec<f64>,
1103 }
1104 impl FitModel for QuadModel {
1105 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1106 let (a, b, c) = (params[0], params[1], params[2]);
1107 Ok(self.x.iter().map(|&x| a * x * x + b * x + c).collect())
1108 }
1109 }
1110
1111 let x: Vec<f64> = (0..20).map(|i| i as f64 * 0.5).collect();
1112 let y_obs: Vec<f64> = x.iter().map(|&xi| 0.5 * xi * xi - 2.0 * xi + 1.0).collect();
1113 let sigma = vec![1.0; 20];
1114
1115 let model = QuadModel { x };
1116 let mut params = ParameterSet::new(vec![
1117 FitParameter::unbounded("a", 1.0),
1118 FitParameter::unbounded("b", 0.0),
1119 FitParameter::unbounded("c", 0.0),
1120 ]);
1121
1122 let result =
1123 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1124
1125 assert!(result.converged);
1126 assert!(
1127 (result.params[0] - 0.5).abs() < 1e-5,
1128 "a = {}",
1129 result.params[0]
1130 );
1131 assert!(
1132 (result.params[1] - (-2.0)).abs() < 1e-5,
1133 "b = {}",
1134 result.params[1]
1135 );
1136 assert!(
1137 (result.params[2] - 1.0).abs() < 1e-5,
1138 "c = {}",
1139 result.params[2]
1140 );
1141 }
1142
1143 #[test]
1144 fn test_non_negative_constraint() {
1145 struct SlopeModel {
1149 x: Vec<f64>,
1150 }
1151 impl FitModel for SlopeModel {
1152 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1153 let a = params[0];
1154 Ok(self.x.iter().map(|&x| a * x).collect())
1155 }
1156 }
1157
1158 let x: Vec<f64> = (1..10).map(|i| i as f64).collect();
1159 let y_obs: Vec<f64> = x.iter().map(|&xi| -2.0 * xi).collect();
1160 let sigma = vec![1.0; 9];
1161
1162 let model = SlopeModel { x };
1163 let mut params = ParameterSet::new(vec![FitParameter::non_negative("a", 1.0)]);
1164
1165 let result =
1166 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1167
1168 assert!(
1170 result.params[0] >= 0.0 && result.params[0] < 0.1,
1171 "a = {}, expected ~0",
1172 result.params[0]
1173 );
1174 }
1175
1176 #[test]
1177 fn test_uncertainty_estimation() {
1178 let x: Vec<f64> = (0..100).map(|i| i as f64 * 0.1).collect();
1180 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1181 let sigma = vec![0.1; 100]; let model = LinearModel { x };
1184 let mut params = ParameterSet::new(vec![
1185 FitParameter::unbounded("a", 1.0),
1186 FitParameter::unbounded("b", 1.0),
1187 ]);
1188
1189 let result =
1190 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1191
1192 assert!(result.converged);
1193 assert!(result.uncertainties.is_some());
1194 let unc = result.uncertainties.unwrap();
1195 assert!(unc[0] > 0.0 && unc[0] < 0.01, "σ_a = {}", unc[0]);
1197 assert!(unc[1] > 0.0 && unc[1] < 0.1, "σ_b = {}", unc[1]);
1198 }
1199
1200 #[test]
1201 fn test_solve_damped_system_identity() {
1202 let a = FlatMatrix {
1204 data: vec![1.0, 0.0, 0.0, 1.0],
1205 nrows: 2,
1206 ncols: 2,
1207 };
1208 let b = vec![2.0, 4.0];
1209 let lambda = 1.0;
1210 let x = solve_damped_system(&a, &b, lambda).unwrap();
1211 assert!((x[0] - 1.0).abs() < 1e-10);
1212 assert!((x[1] - 2.0).abs() < 1e-10);
1213 }
1214
1215 #[test]
1216 fn test_invert_matrix_2x2() {
1217 let a = FlatMatrix {
1218 data: vec![4.0, 7.0, 2.0, 6.0],
1219 nrows: 2,
1220 ncols: 2,
1221 };
1222 let inv = invert_matrix(&a).unwrap();
1223 assert!((inv.get(0, 0) - 0.6).abs() < 1e-10);
1225 assert!((inv.get(0, 1) - (-0.7)).abs() < 1e-10);
1226 assert!((inv.get(1, 0) - (-0.2)).abs() < 1e-10);
1227 assert!((inv.get(1, 1) - 0.4).abs() < 1e-10);
1228 }
1229
1230 #[test]
1233 fn test_all_fixed_params_nan_model() {
1234 struct NanModel;
1237 impl FitModel for NanModel {
1238 fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
1239 Ok(vec![f64::NAN; 5])
1240 }
1241 }
1242
1243 let y_obs = vec![1.0; 5];
1244 let sigma = vec![1.0; 5];
1245 let mut params = ParameterSet::new(vec![FitParameter::fixed("a", 1.0)]);
1246
1247 let result =
1248 levenberg_marquardt(&NanModel, &y_obs, &sigma, &mut params, &LmConfig::default())
1249 .unwrap();
1250
1251 assert!(!result.converged, "All-fixed NaN model should not converge");
1252 assert!(result.chi_squared.is_nan(), "chi2 should be NaN");
1253 assert_eq!(result.iterations, 0);
1254 }
1255
1256 #[test]
1257 fn test_underdetermined_system() {
1258 let y_obs = vec![1.0, 2.0]; let sigma = vec![1.0, 1.0];
1262
1263 struct ThreeParamModel;
1266 impl FitModel for ThreeParamModel {
1267 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1268 Ok(vec![params[0] + params[1] + params[2]; 2])
1269 }
1270 }
1271
1272 let mut params = ParameterSet::new(vec![
1273 FitParameter::unbounded("a", 1.0),
1274 FitParameter::unbounded("b", 1.0),
1275 FitParameter::unbounded("c", 1.0),
1276 ]);
1277
1278 let result = levenberg_marquardt(
1279 &ThreeParamModel,
1280 &y_obs,
1281 &sigma,
1282 &mut params,
1283 &LmConfig::default(),
1284 )
1285 .unwrap();
1286
1287 assert!(
1288 !result.converged,
1289 "Underdetermined system should not converge"
1290 );
1291 assert!(result.chi_squared.is_nan());
1292 assert_eq!(result.iterations, 0);
1293 }
1294
1295 #[test]
1296 fn test_exactly_determined_dof_zero() {
1297 let y_obs = vec![5.0, 11.0]; let sigma = vec![1.0, 1.0];
1301
1302 let model = LinearModel { x: vec![1.0, 4.0] };
1303 let mut params = ParameterSet::new(vec![
1304 FitParameter::unbounded("a", 1.0),
1305 FitParameter::unbounded("b", 1.0),
1306 ]);
1307
1308 let result =
1309 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1310
1311 assert!(
1312 result.converged,
1313 "Exactly-determined system should converge"
1314 );
1315 assert!(
1316 result.chi_squared < 1e-6,
1317 "chi2 should be ~0, got {}",
1318 result.chi_squared
1319 );
1320 assert!(
1322 result.reduced_chi_squared.is_nan(),
1323 "reduced_chi2 should be NaN for dof=0, got {}",
1324 result.reduced_chi_squared
1325 );
1326 assert!(result.covariance.is_none());
1328 assert!(result.uncertainties.is_none());
1329 }
1330
1331 #[test]
1332 fn test_lambda_breakout() {
1333 struct ConstantModel;
1335 impl FitModel for ConstantModel {
1336 fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
1337 Ok(vec![42.0; 5])
1340 }
1341 }
1342
1343 let y_obs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1344 let sigma = vec![1.0; 5];
1345 let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 1.0)]);
1346
1347 let config = LmConfig {
1348 max_iter: 1000,
1349 ..LmConfig::default()
1350 };
1351
1352 let result =
1353 levenberg_marquardt(&ConstantModel, &y_obs, &sigma, &mut params, &config).unwrap();
1354
1355 assert!(
1356 !result.converged,
1357 "Flat model should not converge (lambda breakout)"
1358 );
1359 assert!(
1360 result.covariance.is_none(),
1361 "unconverged fit should not report covariance"
1362 );
1363 assert!(
1364 result.uncertainties.is_none(),
1365 "unconverged fit should not report uncertainties"
1366 );
1367 }
1368
1369 #[test]
1370 fn test_nan_model_during_iteration() {
1371 struct NanAtLargeModel {
1374 x: Vec<f64>,
1375 }
1376 impl FitModel for NanAtLargeModel {
1377 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1378 let a = params[0];
1379 Ok(self
1380 .x
1381 .iter()
1382 .map(|&x| if a > 5.0 { f64::NAN } else { a * x + 1.0 })
1383 .collect())
1384 }
1385 }
1386
1387 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1388 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1389 let sigma = vec![1.0; 10];
1390
1391 let model = NanAtLargeModel { x };
1392 let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 3.0)]);
1393
1394 let result =
1395 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1396
1397 assert!(result.converged, "Should converge avoiding NaN region");
1399 assert!(
1400 (result.params[0] - 2.0).abs() < 0.1,
1401 "a = {}, expected ~2.0",
1402 result.params[0]
1403 );
1404 }
1405
1406 #[test]
1407 fn test_err_model_during_trial_step() {
1408 struct ErrAtLargeModel {
1412 x: Vec<f64>,
1413 }
1414 impl FitModel for ErrAtLargeModel {
1415 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1416 let a = params[0];
1417 if a > 5.0 {
1418 return Err(FittingError::EvaluationFailed(
1419 "parameter out of valid range".into(),
1420 ));
1421 }
1422 Ok(self.x.iter().map(|&x| a * x + 1.0).collect())
1423 }
1424 }
1425
1426 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1427 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
1428 let sigma = vec![1.0; 10];
1429
1430 let model = ErrAtLargeModel { x };
1431 let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 3.0)]);
1432
1433 let result =
1434 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1435
1436 assert!(result.converged, "Should converge avoiding Err region");
1438 assert!(
1439 (result.params[0] - 2.0).abs() < 0.1,
1440 "a = {}, expected ~2.0",
1441 result.params[0]
1442 );
1443 }
1444
1445 #[test]
1446 fn test_fit_linear_no_covariance() {
1447 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1450 let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1451 let sigma = vec![1.0; 10];
1452
1453 let model = LinearModel { x };
1454 let mut params = ParameterSet::new(vec![
1455 FitParameter::unbounded("a", 1.0),
1456 FitParameter::unbounded("b", 1.0),
1457 ]);
1458
1459 let config = LmConfig {
1460 compute_covariance: false,
1461 ..LmConfig::default()
1462 };
1463
1464 let result = levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
1465
1466 assert!(result.converged, "Fit did not converge");
1467 assert!(
1468 (result.params[0] - 2.0).abs() < 1e-4,
1469 "a = {}, expected 2.0",
1470 result.params[0]
1471 );
1472 assert!(
1473 (result.params[1] - 3.0).abs() < 1e-4,
1474 "b = {}, expected 3.0",
1475 result.params[1]
1476 );
1477 assert!(result.chi_squared < 1e-6);
1478 assert!(
1479 result.covariance.is_none(),
1480 "covariance should be None when compute_covariance=false"
1481 );
1482 assert!(
1483 result.uncertainties.is_none(),
1484 "uncertainties should be None when compute_covariance=false"
1485 );
1486 }
1487
1488 #[test]
1489 fn test_zero_negative_sigma_clamping() {
1490 let y_obs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1493 let sigma = vec![0.0, -1.0, f64::NAN, f64::INFINITY, 1.0];
1494
1495 let model = LinearModel {
1496 x: vec![0.0, 1.0, 2.0, 3.0, 4.0],
1497 };
1498 let mut params = ParameterSet::new(vec![
1499 FitParameter::unbounded("a", 1.0),
1500 FitParameter::unbounded("b", 0.0),
1501 ]);
1502
1503 let result =
1505 levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
1506
1507 assert!(
1508 result.chi_squared.is_finite(),
1509 "chi2 should be finite despite bad sigma, got {}",
1510 result.chi_squared
1511 );
1512 assert!(
1513 result.converged,
1514 "Fit should converge despite bad sigma values"
1515 );
1516 let y_at_4 = result.params[0] * 4.0 + result.params[1];
1519 assert!(
1520 (y_at_4 - 5.0).abs() < 1.0,
1521 "Fitted line should pass near (4, 5): a={}, b={}, y(4)={}",
1522 result.params[0],
1523 result.params[1],
1524 y_at_4,
1525 );
1526 }
1527
1528 #[test]
1537 fn test_lm_active_mask_subset_equivalence() {
1538 let x_full: Vec<f64> = (0..10).map(|i| i as f64).collect();
1540 let y_full: Vec<f64> = x_full.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1541 let sigma_full = vec![1.0; 10];
1542 let mask = vec![
1543 true, true, true, true, true, false, false, false, false, false,
1544 ];
1545
1546 let model = LinearModel { x: x_full.clone() };
1547 let mut params = ParameterSet::new(vec![
1548 FitParameter::unbounded("a", 1.0),
1549 FitParameter::unbounded("b", 1.0),
1550 ]);
1551 let result_masked = levenberg_marquardt_with_mask(
1552 &model,
1553 &y_full,
1554 &sigma_full,
1555 &mut params,
1556 &LmConfig::default(),
1557 Some(&mask),
1558 )
1559 .unwrap();
1560 assert!(result_masked.converged);
1561 assert!((result_masked.params[0] - 2.0).abs() < 1e-6);
1562 assert!((result_masked.params[1] - 3.0).abs() < 1e-6);
1563
1564 let x_sub = x_full[..5].to_vec();
1566 let y_sub = y_full[..5].to_vec();
1567 let sigma_sub = vec![1.0; 5];
1568 let model_sub = LinearModel { x: x_sub };
1569 let mut params_sub = ParameterSet::new(vec![
1570 FitParameter::unbounded("a", 1.0),
1571 FitParameter::unbounded("b", 1.0),
1572 ]);
1573 let result_sub = levenberg_marquardt(
1574 &model_sub,
1575 &y_sub,
1576 &sigma_sub,
1577 &mut params_sub,
1578 &LmConfig::default(),
1579 )
1580 .unwrap();
1581
1582 for j in 0..2 {
1585 assert!(
1586 (result_masked.params[j] - result_sub.params[j]).abs() < 1e-6,
1587 "param {j}: masked={} subset={}",
1588 result_masked.params[j],
1589 result_sub.params[j]
1590 );
1591 }
1592 assert!(
1593 (result_masked.reduced_chi_squared - result_sub.reduced_chi_squared).abs() < 1e-6,
1594 "reduced-χ²: masked={} subset={}",
1595 result_masked.reduced_chi_squared,
1596 result_sub.reduced_chi_squared
1597 );
1598 }
1599
1600 #[test]
1604 fn test_lm_active_mask_excludes_out_of_range_residuals() {
1605 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1607 let mut y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1608 for yi in y.iter_mut().skip(5) {
1609 *yi += 1000.0; }
1611 let sigma = vec![1.0; 10];
1612 let mask = vec![true; 5]
1613 .into_iter()
1614 .chain(std::iter::repeat_n(false, 5))
1615 .collect::<Vec<bool>>();
1616
1617 let model = LinearModel { x };
1618 let mut params = ParameterSet::new(vec![
1619 FitParameter::unbounded("a", 1.0),
1620 FitParameter::unbounded("b", 1.0),
1621 ]);
1622 let result = levenberg_marquardt_with_mask(
1623 &model,
1624 &y,
1625 &sigma,
1626 &mut params,
1627 &LmConfig::default(),
1628 Some(&mask),
1629 )
1630 .unwrap();
1631 assert!(result.converged);
1632 assert!(
1633 (result.params[0] - 2.0).abs() < 1e-6,
1634 "slope should be 2.0 (corrupt outliers must be masked out), got {}",
1635 result.params[0]
1636 );
1637 assert!(
1638 (result.params[1] - 3.0).abs() < 1e-6,
1639 "intercept should be 3.0 (corrupt outliers must be masked out), got {}",
1640 result.params[1]
1641 );
1642 }
1643
1644 #[test]
1650 fn test_lm_active_mask_tolerates_nan_outside_range() {
1651 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1655 let mut y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1656 for yi in y.iter_mut().skip(5) {
1657 *yi = f64::NAN;
1658 }
1659 let sigma = vec![1.0; 10];
1660 let mask = vec![true; 5]
1661 .into_iter()
1662 .chain(std::iter::repeat_n(false, 5))
1663 .collect::<Vec<bool>>();
1664
1665 let model = LinearModel { x };
1666 let mut params = ParameterSet::new(vec![
1667 FitParameter::unbounded("a", 1.0),
1668 FitParameter::unbounded("b", 1.0),
1669 ]);
1670 let result = levenberg_marquardt_with_mask(
1671 &model,
1672 &y,
1673 &sigma,
1674 &mut params,
1675 &LmConfig::default(),
1676 Some(&mask),
1677 )
1678 .unwrap();
1679
1680 assert!(
1681 result.converged,
1682 "fit should converge despite NaN outside the active mask"
1683 );
1684 assert!(
1685 result.chi_squared.is_finite(),
1686 "χ² should be finite (NaN poisoning prevented), got {}",
1687 result.chi_squared
1688 );
1689 assert!(
1690 (result.params[0] - 2.0).abs() < 1e-6,
1691 "slope should be 2.0, got {}",
1692 result.params[0]
1693 );
1694 assert!(
1695 (result.params[1] - 3.0).abs() < 1e-6,
1696 "intercept should be 3.0, got {}",
1697 result.params[1]
1698 );
1699 }
1700
1701 #[test]
1706 fn test_lm_active_mask_tolerates_model_nan_outside_range() {
1707 struct LinearModelWithMaskedNaN {
1711 x: Vec<f64>,
1712 mask: Vec<bool>,
1713 }
1714 impl FitModel for LinearModelWithMaskedNaN {
1715 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1716 let a = params[0];
1717 let b = params[1];
1718 Ok(self
1719 .x
1720 .iter()
1721 .enumerate()
1722 .map(|(i, &x)| if self.mask[i] { a * x + b } else { f64::NAN })
1723 .collect())
1724 }
1725 }
1726
1727 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1728 let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1729 let sigma = vec![1.0; 10];
1730 let mask: Vec<bool> = vec![true; 5]
1731 .into_iter()
1732 .chain(std::iter::repeat_n(false, 5))
1733 .collect();
1734
1735 let model = LinearModelWithMaskedNaN {
1736 x: x.clone(),
1737 mask: mask.clone(),
1738 };
1739 let mut params = ParameterSet::new(vec![
1740 FitParameter::unbounded("a", 1.0),
1741 FitParameter::unbounded("b", 1.0),
1742 ]);
1743 let result = levenberg_marquardt_with_mask(
1744 &model,
1745 &y,
1746 &sigma,
1747 &mut params,
1748 &LmConfig::default(),
1749 Some(&mask),
1750 )
1751 .unwrap();
1752
1753 assert!(
1754 result.converged,
1755 "fit should converge despite model NaN at masked bins"
1756 );
1757 assert!(result.chi_squared.is_finite());
1758 assert!((result.params[0] - 2.0).abs() < 1e-6);
1759 assert!((result.params[1] - 3.0).abs() < 1e-6);
1760 }
1761
1762 #[test]
1768 fn test_lm_active_mask_all_false_returns_non_converged() {
1769 let x: Vec<f64> = (0..5).map(|i| i as f64).collect();
1770 let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
1771 let sigma = vec![1.0; 5];
1772 let mask = vec![false; 5];
1773 let model = LinearModel { x: x.clone() };
1774
1775 let mut params_free = ParameterSet::new(vec![
1777 FitParameter::unbounded("a", 1.0),
1778 FitParameter::unbounded("b", 1.0),
1779 ]);
1780 let r_free = levenberg_marquardt_with_mask(
1781 &model,
1782 &y,
1783 &sigma,
1784 &mut params_free,
1785 &LmConfig::default(),
1786 Some(&mask),
1787 )
1788 .unwrap();
1789 assert!(
1790 !r_free.converged,
1791 "n_free > 0 + zero-active mask must NOT report converged"
1792 );
1793 assert!(r_free.chi_squared.is_nan());
1794 assert!(r_free.reduced_chi_squared.is_nan());
1795
1796 let mut params_fixed = ParameterSet::new(vec![
1799 FitParameter::fixed("a", 1.0),
1800 FitParameter::fixed("b", 0.0),
1801 ]);
1802 let r_fixed = levenberg_marquardt_with_mask(
1803 &model,
1804 &y,
1805 &sigma,
1806 &mut params_fixed,
1807 &LmConfig::default(),
1808 Some(&mask),
1809 )
1810 .unwrap();
1811 assert!(
1812 !r_fixed.converged,
1813 "n_free == 0 + zero-active mask must NOT report converged \
1814 (sum over zero rows would be 0, masquerading as a perfect fit)"
1815 );
1816 assert!(r_fixed.chi_squared.is_nan());
1817 assert!(r_fixed.reduced_chi_squared.is_nan());
1818 }
1819
1820 #[test]
1844 fn test_compute_jacobian_skips_nan_perturbed_column() {
1845 use crate::parameters::FitParameter;
1846 struct NanInColumn1 {
1851 p1_base: f64,
1853 }
1854 impl FitModel for NanInColumn1 {
1855 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1856 let nan_row = (params[1] - self.p1_base).abs() > 1e-12;
1859 let v = if nan_row { f64::NAN } else { params[0] };
1860 Ok(vec![v; 4])
1861 }
1862 }
1864 let model = NanInColumn1 { p1_base: 0.3 };
1865 let mut params = ParameterSet::new(vec![
1866 FitParameter::unbounded("p0", 0.5),
1867 FitParameter::unbounded("p1", 0.3),
1868 ]);
1869 let y_current = vec![0.5; 4];
1870 let mut all_vals_buf: Vec<f64> = Vec::new();
1871 let mut free_idx_buf: Vec<usize> = Vec::new();
1872 let jac = compute_jacobian(
1873 &model,
1874 &mut params,
1875 &y_current,
1876 1e-6,
1877 &mut all_vals_buf,
1878 &mut free_idx_buf,
1879 )
1880 .unwrap();
1881 for (i, v) in jac.data.iter().enumerate() {
1883 assert!(
1884 v.is_finite(),
1885 "compute_jacobian produced non-finite entry at index {i}: {v}"
1886 );
1887 }
1888 for i in 0..jac.nrows {
1891 assert_eq!(
1892 jac.get(i, 1),
1893 0.0,
1894 "column 1 (NaN probe) should be zeroed, row {i}"
1895 );
1896 }
1897 }
1898}