use nereids_core::constants::{LM_DIAGONAL_FLOOR, PIVOT_FLOOR};
use crate::error::FittingError;
use crate::parameters::ParameterSet;
#[derive(Debug, Clone)]
pub struct FlatMatrix {
pub data: Vec<f64>,
pub nrows: usize,
pub ncols: usize,
}
impl FlatMatrix {
pub fn zeros(nrows: usize, ncols: usize) -> Self {
let len = nrows
.checked_mul(ncols)
.expect("FlatMatrix dimensions overflow usize");
Self {
data: vec![0.0; len],
nrows,
ncols,
}
}
#[inline(always)]
pub fn get(&self, row: usize, col: usize) -> f64 {
debug_assert!(row < self.nrows && col < self.ncols);
self.data[row * self.ncols + col]
}
#[inline(always)]
pub fn get_mut(&mut self, row: usize, col: usize) -> &mut f64 {
debug_assert!(row < self.nrows && col < self.ncols);
&mut self.data[row * self.ncols + col]
}
}
const LAMBDA_BREAKOUT: f64 = 1e16;
#[derive(Debug, Clone)]
pub struct LmConfig {
pub max_iter: usize,
pub lambda_init: f64,
pub lambda_up: f64,
pub lambda_down: f64,
pub tol_chi2: f64,
pub tol_param: f64,
pub fd_step: f64,
pub compute_covariance: bool,
}
impl Default for LmConfig {
fn default() -> Self {
Self {
max_iter: 200,
lambda_init: 1e-3,
lambda_up: 10.0,
lambda_down: 0.1,
tol_chi2: 1e-8,
tol_param: 1e-8,
fd_step: 1e-6,
compute_covariance: true,
}
}
}
#[derive(Debug, Clone)]
pub struct LmResult {
pub chi_squared: f64,
pub reduced_chi_squared: f64,
pub iterations: usize,
pub converged: bool,
pub params: Vec<f64>,
pub covariance: Option<FlatMatrix>,
pub uncertainties: Option<Vec<f64>>,
}
pub trait FitModel {
fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError>;
fn analytical_jacobian(
&self,
_params: &[f64],
_free_param_indices: &[usize],
_y_current: &[f64],
) -> Option<FlatMatrix> {
None
}
}
impl<M: FitModel + ?Sized> FitModel for &M {
fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
(**self).evaluate(params)
}
fn analytical_jacobian(
&self,
params: &[f64],
free_param_indices: &[usize],
y_current: &[f64],
) -> Option<FlatMatrix> {
(**self).analytical_jacobian(params, free_param_indices, y_current)
}
}
fn chi_squared(residuals: &[f64], weights: &[f64], active_mask: Option<&[bool]>) -> f64 {
let mut sum = 0.0;
for (i, (&r, &w)) in residuals.iter().zip(weights.iter()).enumerate() {
if active_mask.is_some_and(|m| !m[i]) {
continue;
}
sum += r * r * w;
}
sum
}
fn scaled_gradient_inf_norm(jtw_j: &FlatMatrix, jtw_r: &[f64], chi2: f64) -> f64 {
let residual_scale = chi2.sqrt().max(1.0);
let mut max_scaled: f64 = 0.0;
for (j, &grad_j) in jtw_r.iter().enumerate() {
let curvature = jtw_j.get(j, j).abs().sqrt();
let scale = curvature * residual_scale + PIVOT_FLOOR;
max_scaled = max_scaled.max(grad_j.abs() / scale);
}
max_scaled
}
fn has_informative_curvature(jtw_j: &FlatMatrix) -> bool {
(0..jtw_j.nrows).any(|j| jtw_j.get(j, j) > LM_DIAGONAL_FLOOR)
}
fn compute_jacobian(
model: &dyn FitModel,
params: &mut ParameterSet,
y_current: &[f64],
fd_step: f64,
all_vals_buf: &mut Vec<f64>,
free_idx_buf: &mut Vec<usize>,
) -> Result<FlatMatrix, FittingError> {
params.free_indices_into(free_idx_buf);
let n_free = free_idx_buf.len();
let n_data = y_current.len();
params.all_values_into(all_vals_buf);
if let Some(j) = model.analytical_jacobian(all_vals_buf, free_idx_buf, y_current) {
debug_assert!(
j.nrows == n_data && j.ncols == n_free && j.data.len() == n_data * n_free,
"analytical_jacobian shape mismatch: got ({}x{}, len={}), expected ({}x{}, len={})",
j.nrows,
j.ncols,
j.data.len(),
n_data,
n_free,
n_data * n_free,
);
return Ok(j);
}
let mut jacobian = FlatMatrix::zeros(n_data, n_free);
for (j, &idx) in free_idx_buf.iter().enumerate() {
let original = params.params[idx].value;
let step = fd_step * (1.0 + original.abs());
params.params[idx].value = original + step;
params.params[idx].clamp();
let mut actual_step = params.params[idx].value - original;
if actual_step.abs() < PIVOT_FLOOR {
params.params[idx].value = original - step;
params.params[idx].clamp();
actual_step = params.params[idx].value - original;
if actual_step.abs() < PIVOT_FLOOR {
params.params[idx].value = original;
continue;
}
}
params.all_values_into(all_vals_buf);
let perturbed = match model.evaluate(all_vals_buf) {
Ok(v) => v,
Err(_) => {
params.params[idx].value = original;
continue;
}
};
params.params[idx].value = original;
for i in 0..n_data {
let p = perturbed[i];
let y = y_current[i];
if p.is_finite() && y.is_finite() {
*jacobian.get_mut(i, j) = (p - y) / actual_step;
}
}
}
Ok(jacobian)
}
pub(crate) fn solve_damped_system(a: &FlatMatrix, b: &[f64], lambda: f64) -> Option<Vec<f64>> {
let n = b.len();
if n == 0 {
return Some(vec![]);
}
let ncols = n + 1;
let mut aug = FlatMatrix::zeros(n, ncols);
for (i, &bi) in b.iter().enumerate() {
for j in 0..n {
*aug.get_mut(i, j) = a.get(i, j);
}
*aug.get_mut(i, i) += lambda * a.get(i, i).max(LM_DIAGONAL_FLOOR); *aug.get_mut(i, n) = bi;
}
for col in 0..n {
let mut max_val = aug.get(col, col).abs();
let mut max_row = col;
for row in (col + 1)..n {
if aug.get(row, col).abs() > max_val {
max_val = aug.get(row, col).abs();
max_row = row;
}
}
if max_val < PIVOT_FLOOR {
return None; }
if col != max_row {
let (row_a, row_b) = (col * ncols, max_row * ncols);
let (first, second) = aug.data.split_at_mut(row_b);
first[row_a..row_a + ncols].swap_with_slice(&mut second[..ncols]);
}
let pivot = aug.get(col, col);
for row in (col + 1)..n {
let factor = aug.get(row, col) / pivot;
for j in col..=n {
let val = aug.get(col, j);
*aug.get_mut(row, j) -= factor * val;
}
}
}
let mut x = vec![0.0; n];
for i in (0..n).rev() {
let mut sum = aug.get(i, n);
for (j, &xj) in x.iter().enumerate().skip(i + 1) {
sum -= aug.get(i, j) * xj;
}
x[i] = sum / aug.get(i, i);
}
Some(x)
}
pub(crate) fn invert_matrix(a: &FlatMatrix) -> Option<FlatMatrix> {
let n = a.nrows;
if n == 0 {
return Some(FlatMatrix::zeros(0, 0));
}
let ncols = 2 * n;
let mut aug = FlatMatrix::zeros(n, ncols);
for i in 0..n {
for j in 0..n {
*aug.get_mut(i, j) = a.get(i, j);
}
*aug.get_mut(i, n + i) = 1.0;
}
for col in 0..n {
let mut max_val = aug.get(col, col).abs();
let mut max_row = col;
for row in (col + 1)..n {
if aug.get(row, col).abs() > max_val {
max_val = aug.get(row, col).abs();
max_row = row;
}
}
if max_val < PIVOT_FLOOR {
return None;
}
if col != max_row {
let (row_a, row_b) = (col * ncols, max_row * ncols);
let (first, second) = aug.data.split_at_mut(row_b);
first[row_a..row_a + ncols].swap_with_slice(&mut second[..ncols]);
}
let pivot = aug.get(col, col);
for j in 0..ncols {
*aug.get_mut(col, j) /= pivot;
}
for row in 0..n {
if row != col {
let factor = aug.get(row, col);
for j in 0..ncols {
let val = aug.get(col, j);
*aug.get_mut(row, j) -= factor * val;
}
}
}
}
let mut inv = FlatMatrix::zeros(n, n);
for i in 0..n {
for j in 0..n {
*inv.get_mut(i, j) = aug.get(i, n + j);
}
}
Some(inv)
}
pub fn levenberg_marquardt(
model: &dyn FitModel,
y_obs: &[f64],
sigma: &[f64],
params: &mut ParameterSet,
config: &LmConfig,
) -> Result<LmResult, FittingError> {
levenberg_marquardt_with_mask(model, y_obs, sigma, params, config, None)
}
pub fn levenberg_marquardt_with_mask(
model: &dyn FitModel,
y_obs: &[f64],
sigma: &[f64],
params: &mut ParameterSet,
config: &LmConfig,
active_mask: Option<&[bool]>,
) -> Result<LmResult, FittingError> {
let n_data = y_obs.len();
if n_data == 0 {
return Err(FittingError::EmptyData);
}
if sigma.len() != n_data {
return Err(FittingError::LengthMismatch {
expected: n_data,
actual: sigma.len(),
field: "sigma",
});
}
if let Some(m) = active_mask
&& m.len() != n_data
{
return Err(FittingError::LengthMismatch {
expected: n_data,
actual: m.len(),
field: "active_mask",
});
}
let n_active = crate::active_mask::active_count(active_mask, n_data);
if n_active == 0 {
return Ok(LmResult {
chi_squared: f64::NAN,
reduced_chi_squared: f64::NAN,
iterations: 0,
converged: false,
params: params.all_values(),
covariance: None,
uncertainties: None,
});
}
let n_free = params.n_free();
if n_free == 0 {
let weights: Vec<f64> = sigma
.iter()
.enumerate()
.map(|(i, &s)| {
if active_mask.is_some_and(|m| !m[i]) {
return 0.0;
}
if !s.is_finite() || s <= 0.0 {
1.0 / 1e30
} else {
1.0 / (s * s)
}
})
.collect();
let y_model = model.evaluate(¶ms.all_values())?;
let model_has_active_nonfinite = y_model
.iter()
.enumerate()
.any(|(i, v)| active_mask.is_none_or(|m| m[i]) && !v.is_finite());
if model_has_active_nonfinite {
return Ok(LmResult {
chi_squared: f64::NAN,
reduced_chi_squared: f64::NAN,
iterations: 0,
converged: false,
params: params.all_values(),
covariance: None,
uncertainties: None,
});
}
let residuals: Vec<f64> = y_obs
.iter()
.zip(y_model.iter())
.map(|(&obs, &mdl)| obs - mdl)
.collect();
let chi2 = chi_squared(&residuals, &weights, active_mask);
let dof = n_active.saturating_sub(n_free);
let reduced = if dof > 0 { chi2 / dof as f64 } else { f64::NAN };
return Ok(LmResult {
chi_squared: chi2,
reduced_chi_squared: reduced,
iterations: 0,
converged: true,
params: params.all_values(),
covariance: Some(FlatMatrix::zeros(0, 0)),
uncertainties: Some(vec![]),
});
}
if n_active < n_free {
return Ok(LmResult {
chi_squared: f64::NAN,
reduced_chi_squared: f64::NAN,
iterations: 0,
converged: false,
params: params.all_values(),
covariance: None,
uncertainties: None,
});
}
let dof = n_active - n_free;
let weights: Vec<f64> = sigma
.iter()
.enumerate()
.map(|(i, &s)| {
if active_mask.is_some_and(|m| !m[i]) {
return 0.0;
}
if !s.is_finite() || s <= 0.0 {
1.0 / 1e30
} else {
1.0 / (s * s)
}
})
.collect();
let mut all_vals_buf = Vec::with_capacity(params.params.len());
let mut free_vals_buf = Vec::with_capacity(n_free);
let mut free_idx_buf = Vec::with_capacity(n_free);
params.all_values_into(&mut all_vals_buf);
let mut y_current = model.evaluate(&all_vals_buf)?;
let mut residuals: Vec<f64> = y_obs
.iter()
.zip(y_current.iter())
.map(|(&obs, &mdl)| obs - mdl)
.collect();
let mut chi2 = chi_squared(&residuals, &weights, active_mask);
let mut lambda = config.lambda_init;
let mut converged = false;
let mut iter = 0;
for _ in 0..config.max_iter {
iter += 1;
let jacobian = compute_jacobian(
model,
params,
&y_current,
config.fd_step,
&mut all_vals_buf,
&mut free_idx_buf,
)?;
let mut jtw_j = FlatMatrix::zeros(n_free, n_free);
let mut jtw_r = vec![0.0; n_free];
for (i, (&wi, &ri)) in weights.iter().zip(residuals.iter()).enumerate() {
if active_mask.is_some_and(|m| !m[i]) {
continue;
}
for (j, jtw_r_j) in jtw_r.iter_mut().enumerate() {
let jij = jacobian.get(i, j);
*jtw_r_j += jij * wi * ri;
for k in 0..n_free {
*jtw_j.get_mut(j, k) += jij * wi * jacobian.get(i, k);
}
}
}
let scaled_grad_inf = scaled_gradient_inf_norm(&jtw_j, &jtw_r, chi2);
let informative_curvature = has_informative_curvature(&jtw_j);
let delta = match solve_damped_system(&jtw_j, &jtw_r, lambda) {
Some(d) => d,
None => break, };
params.free_values_into(&mut free_vals_buf);
let trial_free: Vec<f64> = free_vals_buf
.iter()
.zip(delta.iter())
.map(|(&v, &d)| v + d)
.collect();
let param_change: f64 = delta
.iter()
.zip(free_vals_buf.iter())
.map(|(&d, &v)| (d / (v.abs() + PIVOT_FLOOR)).powi(2))
.sum::<f64>()
.sqrt();
params.set_free_values(&trial_free);
params.all_values_into(&mut all_vals_buf);
let y_trial = match model.evaluate(&all_vals_buf) {
Ok(y) => y,
Err(_) => {
params.set_free_values(&free_vals_buf);
lambda *= config.lambda_up;
if lambda > LAMBDA_BREAKOUT {
converged = false;
break;
}
continue;
}
};
let trial_has_active_nonfinite = y_trial
.iter()
.enumerate()
.any(|(i, v)| active_mask.is_none_or(|m| m[i]) && !v.is_finite());
if trial_has_active_nonfinite {
params.set_free_values(&free_vals_buf);
lambda *= config.lambda_up;
if lambda > LAMBDA_BREAKOUT {
converged = false;
break;
}
continue;
}
let trial_residuals: Vec<f64> = y_obs
.iter()
.zip(y_trial.iter())
.map(|(&obs, &mdl)| obs - mdl)
.collect();
let trial_chi2 = chi_squared(&trial_residuals, &weights, active_mask);
let chi2_delta = (trial_chi2 - chi2).abs();
let chi2_scale = chi2.abs().max(trial_chi2.abs()).max(1.0);
let chi2_stagnated = chi2_delta <= config.tol_chi2 * chi2_scale;
if trial_chi2 < chi2 {
let rel_change = (chi2 - trial_chi2) / (chi2 + PIVOT_FLOOR);
chi2 = trial_chi2;
residuals = trial_residuals;
y_current = y_trial;
lambda *= config.lambda_down;
if rel_change < config.tol_chi2 || param_change < config.tol_param {
converged = true;
break;
}
} else {
let grad_tol = config.tol_chi2.sqrt().max(config.tol_param.sqrt());
if chi2_stagnated
&& param_change < config.tol_param
&& scaled_grad_inf < grad_tol
&& informative_curvature
{
params.set_free_values(&free_vals_buf);
converged = true;
break;
}
params.set_free_values(&free_vals_buf);
lambda *= config.lambda_up;
if lambda > LAMBDA_BREAKOUT {
converged = false;
break;
}
}
}
let reduced_chi2 = if dof > 0 { chi2 / dof as f64 } else { f64::NAN };
let (covariance, uncertainties) = if converged && config.compute_covariance {
let jacobian = compute_jacobian(
model,
params,
&y_current,
config.fd_step,
&mut all_vals_buf,
&mut free_idx_buf,
)?;
let mut jtw_j = FlatMatrix::zeros(n_free, n_free);
for (i, &wi) in weights.iter().enumerate() {
if active_mask.is_some_and(|m| !m[i]) {
continue;
}
for j in 0..n_free {
let jij = jacobian.get(i, j);
for k in 0..n_free {
*jtw_j.get_mut(j, k) += jij * wi * jacobian.get(i, k);
}
}
}
if dof > 0 {
if let Some(mut cov) = invert_matrix(&jtw_j) {
for elem in cov.data.iter_mut() {
*elem *= reduced_chi2;
}
let unc: Vec<f64> = (0..n_free)
.map(|i| {
let diag = cov.get(i, i);
if diag.is_finite() && diag > 0.0 {
diag.sqrt()
} else {
f64::NAN
}
})
.collect();
(Some(cov), Some(unc))
} else {
(None, None)
}
} else {
(None, None)
}
} else {
(None, None)
};
Ok(LmResult {
chi_squared: chi2,
reduced_chi_squared: reduced_chi2,
iterations: iter,
converged,
params: params.all_values(),
covariance,
uncertainties,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parameters::{FitParameter, ParameterSet};
struct LinearModel {
x: Vec<f64>,
}
impl FitModel for LinearModel {
fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
let a = params[0];
let b = params[1];
Ok(self.x.iter().map(|&x| a * x + b).collect())
}
}
#[test]
fn test_fit_linear_exact() {
let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
let sigma = vec![1.0; 10];
let model = LinearModel { x };
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0), FitParameter::unbounded("b", 1.0),
]);
let result =
levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
assert!(result.converged, "Fit did not converge");
assert!(
(result.params[0] - 2.0).abs() < 1e-4,
"a = {}, expected 2.0",
result.params[0]
);
assert!(
(result.params[1] - 3.0).abs() < 1e-4,
"b = {}, expected 3.0",
result.params[1]
);
assert!(result.chi_squared < 1e-6);
}
#[test]
fn test_converges_on_exact_flat_bottom_without_lambda_breakout() {
let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
let sigma = vec![1.0; 10];
let model = LinearModel { x };
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 0.0),
FitParameter::unbounded("b", 0.0),
]);
let config = LmConfig {
lambda_init: 0.0,
..LmConfig::default()
};
let result = levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
assert!(
result.converged,
"Fit should converge on an exact flat bottom"
);
assert!(result.chi_squared < 1e-20, "chi2 = {}", result.chi_squared);
assert!(
result.iterations < config.max_iter,
"LM should stop by convergence, not by iteration exhaustion"
);
}
#[test]
fn test_converges_on_nonzero_chi2_stationary_point() {
struct AffineModel {
x: Vec<f64>,
}
impl FitModel for AffineModel {
fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
let a = params[0];
let b = params[1];
Ok(self.x.iter().map(|&x| a * x + b).collect())
}
}
let model = AffineModel {
x: vec![0.0, 1.0, 2.0, 3.0, 4.0],
};
let y_obs = vec![0.1, 0.9, 2.2, 2.8, 4.1];
let sigma = vec![1.0; y_obs.len()];
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 0.0),
FitParameter::unbounded("b", 0.0),
]);
let config = LmConfig {
max_iter: 200,
tol_chi2: 1e-16,
tol_param: 1e-16,
..LmConfig::default()
};
let result = levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
assert!(
result.converged,
"stationary nonzero-chi2 optimum should converge instead of lambda breakout"
);
assert!(
result.reduced_chi_squared.is_finite() && result.reduced_chi_squared > 0.0,
"expected nonzero reduced chi2 at noisy optimum, got {}",
result.reduced_chi_squared
);
}
#[test]
fn test_fit_linear_with_fixed_param() {
let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
let sigma = vec![1.0; 10];
let model = LinearModel { x };
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0),
FitParameter::fixed("b", 3.0),
]);
let result =
levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
assert!(result.converged);
assert!(
(result.params[0] - 2.0).abs() < 1e-6,
"a = {}",
result.params[0]
);
assert_eq!(result.params[1], 3.0); }
#[test]
fn test_fit_quadratic() {
struct QuadModel {
x: Vec<f64>,
}
impl FitModel for QuadModel {
fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
let (a, b, c) = (params[0], params[1], params[2]);
Ok(self.x.iter().map(|&x| a * x * x + b * x + c).collect())
}
}
let x: Vec<f64> = (0..20).map(|i| i as f64 * 0.5).collect();
let y_obs: Vec<f64> = x.iter().map(|&xi| 0.5 * xi * xi - 2.0 * xi + 1.0).collect();
let sigma = vec![1.0; 20];
let model = QuadModel { x };
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0),
FitParameter::unbounded("b", 0.0),
FitParameter::unbounded("c", 0.0),
]);
let result =
levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
assert!(result.converged);
assert!(
(result.params[0] - 0.5).abs() < 1e-5,
"a = {}",
result.params[0]
);
assert!(
(result.params[1] - (-2.0)).abs() < 1e-5,
"b = {}",
result.params[1]
);
assert!(
(result.params[2] - 1.0).abs() < 1e-5,
"c = {}",
result.params[2]
);
}
#[test]
fn test_non_negative_constraint() {
struct SlopeModel {
x: Vec<f64>,
}
impl FitModel for SlopeModel {
fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
let a = params[0];
Ok(self.x.iter().map(|&x| a * x).collect())
}
}
let x: Vec<f64> = (1..10).map(|i| i as f64).collect();
let y_obs: Vec<f64> = x.iter().map(|&xi| -2.0 * xi).collect();
let sigma = vec![1.0; 9];
let model = SlopeModel { x };
let mut params = ParameterSet::new(vec![FitParameter::non_negative("a", 1.0)]);
let result =
levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
assert!(
result.params[0] >= 0.0 && result.params[0] < 0.1,
"a = {}, expected ~0",
result.params[0]
);
}
#[test]
fn test_uncertainty_estimation() {
let x: Vec<f64> = (0..100).map(|i| i as f64 * 0.1).collect();
let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
let sigma = vec![0.1; 100];
let model = LinearModel { x };
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0),
FitParameter::unbounded("b", 1.0),
]);
let result =
levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
assert!(result.converged);
assert!(result.uncertainties.is_some());
let unc = result.uncertainties.unwrap();
assert!(unc[0] > 0.0 && unc[0] < 0.01, "σ_a = {}", unc[0]);
assert!(unc[1] > 0.0 && unc[1] < 0.1, "σ_b = {}", unc[1]);
}
#[test]
fn test_solve_damped_system_identity() {
let a = FlatMatrix {
data: vec![1.0, 0.0, 0.0, 1.0],
nrows: 2,
ncols: 2,
};
let b = vec![2.0, 4.0];
let lambda = 1.0;
let x = solve_damped_system(&a, &b, lambda).unwrap();
assert!((x[0] - 1.0).abs() < 1e-10);
assert!((x[1] - 2.0).abs() < 1e-10);
}
#[test]
fn test_invert_matrix_2x2() {
let a = FlatMatrix {
data: vec![4.0, 7.0, 2.0, 6.0],
nrows: 2,
ncols: 2,
};
let inv = invert_matrix(&a).unwrap();
assert!((inv.get(0, 0) - 0.6).abs() < 1e-10);
assert!((inv.get(0, 1) - (-0.7)).abs() < 1e-10);
assert!((inv.get(1, 0) - (-0.2)).abs() < 1e-10);
assert!((inv.get(1, 1) - 0.4).abs() < 1e-10);
}
#[test]
fn test_all_fixed_params_nan_model() {
struct NanModel;
impl FitModel for NanModel {
fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
Ok(vec![f64::NAN; 5])
}
}
let y_obs = vec![1.0; 5];
let sigma = vec![1.0; 5];
let mut params = ParameterSet::new(vec![FitParameter::fixed("a", 1.0)]);
let result =
levenberg_marquardt(&NanModel, &y_obs, &sigma, &mut params, &LmConfig::default())
.unwrap();
assert!(!result.converged, "All-fixed NaN model should not converge");
assert!(result.chi_squared.is_nan(), "chi2 should be NaN");
assert_eq!(result.iterations, 0);
}
#[test]
fn test_underdetermined_system() {
let y_obs = vec![1.0, 2.0]; let sigma = vec![1.0, 1.0];
struct ThreeParamModel;
impl FitModel for ThreeParamModel {
fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
Ok(vec![params[0] + params[1] + params[2]; 2])
}
}
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0),
FitParameter::unbounded("b", 1.0),
FitParameter::unbounded("c", 1.0),
]);
let result = levenberg_marquardt(
&ThreeParamModel,
&y_obs,
&sigma,
&mut params,
&LmConfig::default(),
)
.unwrap();
assert!(
!result.converged,
"Underdetermined system should not converge"
);
assert!(result.chi_squared.is_nan());
assert_eq!(result.iterations, 0);
}
#[test]
fn test_exactly_determined_dof_zero() {
let y_obs = vec![5.0, 11.0]; let sigma = vec![1.0, 1.0];
let model = LinearModel { x: vec![1.0, 4.0] };
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0),
FitParameter::unbounded("b", 1.0),
]);
let result =
levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
assert!(
result.converged,
"Exactly-determined system should converge"
);
assert!(
result.chi_squared < 1e-6,
"chi2 should be ~0, got {}",
result.chi_squared
);
assert!(
result.reduced_chi_squared.is_nan(),
"reduced_chi2 should be NaN for dof=0, got {}",
result.reduced_chi_squared
);
assert!(result.covariance.is_none());
assert!(result.uncertainties.is_none());
}
#[test]
fn test_lambda_breakout() {
struct ConstantModel;
impl FitModel for ConstantModel {
fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
Ok(vec![42.0; 5])
}
}
let y_obs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let sigma = vec![1.0; 5];
let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 1.0)]);
let config = LmConfig {
max_iter: 1000,
..LmConfig::default()
};
let result =
levenberg_marquardt(&ConstantModel, &y_obs, &sigma, &mut params, &config).unwrap();
assert!(
!result.converged,
"Flat model should not converge (lambda breakout)"
);
assert!(
result.covariance.is_none(),
"unconverged fit should not report covariance"
);
assert!(
result.uncertainties.is_none(),
"unconverged fit should not report uncertainties"
);
}
#[test]
fn test_nan_model_during_iteration() {
struct NanAtLargeModel {
x: Vec<f64>,
}
impl FitModel for NanAtLargeModel {
fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
let a = params[0];
Ok(self
.x
.iter()
.map(|&x| if a > 5.0 { f64::NAN } else { a * x + 1.0 })
.collect())
}
}
let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
let sigma = vec![1.0; 10];
let model = NanAtLargeModel { x };
let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 3.0)]);
let result =
levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
assert!(result.converged, "Should converge avoiding NaN region");
assert!(
(result.params[0] - 2.0).abs() < 0.1,
"a = {}, expected ~2.0",
result.params[0]
);
}
#[test]
fn test_err_model_during_trial_step() {
struct ErrAtLargeModel {
x: Vec<f64>,
}
impl FitModel for ErrAtLargeModel {
fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
let a = params[0];
if a > 5.0 {
return Err(FittingError::EvaluationFailed(
"parameter out of valid range".into(),
));
}
Ok(self.x.iter().map(|&x| a * x + 1.0).collect())
}
}
let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 1.0).collect();
let sigma = vec![1.0; 10];
let model = ErrAtLargeModel { x };
let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 3.0)]);
let result =
levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
assert!(result.converged, "Should converge avoiding Err region");
assert!(
(result.params[0] - 2.0).abs() < 0.1,
"a = {}, expected ~2.0",
result.params[0]
);
}
#[test]
fn test_fit_linear_no_covariance() {
let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
let y_obs: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
let sigma = vec![1.0; 10];
let model = LinearModel { x };
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0),
FitParameter::unbounded("b", 1.0),
]);
let config = LmConfig {
compute_covariance: false,
..LmConfig::default()
};
let result = levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
assert!(result.converged, "Fit did not converge");
assert!(
(result.params[0] - 2.0).abs() < 1e-4,
"a = {}, expected 2.0",
result.params[0]
);
assert!(
(result.params[1] - 3.0).abs() < 1e-4,
"b = {}, expected 3.0",
result.params[1]
);
assert!(result.chi_squared < 1e-6);
assert!(
result.covariance.is_none(),
"covariance should be None when compute_covariance=false"
);
assert!(
result.uncertainties.is_none(),
"uncertainties should be None when compute_covariance=false"
);
}
#[test]
fn test_zero_negative_sigma_clamping() {
let y_obs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let sigma = vec![0.0, -1.0, f64::NAN, f64::INFINITY, 1.0];
let model = LinearModel {
x: vec![0.0, 1.0, 2.0, 3.0, 4.0],
};
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0),
FitParameter::unbounded("b", 0.0),
]);
let result =
levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default()).unwrap();
assert!(
result.chi_squared.is_finite(),
"chi2 should be finite despite bad sigma, got {}",
result.chi_squared
);
assert!(
result.converged,
"Fit should converge despite bad sigma values"
);
let y_at_4 = result.params[0] * 4.0 + result.params[1];
assert!(
(y_at_4 - 5.0).abs() < 1.0,
"Fitted line should pass near (4, 5): a={}, b={}, y(4)={}",
result.params[0],
result.params[1],
y_at_4,
);
}
#[test]
fn test_lm_active_mask_subset_equivalence() {
let x_full: Vec<f64> = (0..10).map(|i| i as f64).collect();
let y_full: Vec<f64> = x_full.iter().map(|&xi| 2.0 * xi + 3.0).collect();
let sigma_full = vec![1.0; 10];
let mask = vec![
true, true, true, true, true, false, false, false, false, false,
];
let model = LinearModel { x: x_full.clone() };
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0),
FitParameter::unbounded("b", 1.0),
]);
let result_masked = levenberg_marquardt_with_mask(
&model,
&y_full,
&sigma_full,
&mut params,
&LmConfig::default(),
Some(&mask),
)
.unwrap();
assert!(result_masked.converged);
assert!((result_masked.params[0] - 2.0).abs() < 1e-6);
assert!((result_masked.params[1] - 3.0).abs() < 1e-6);
let x_sub = x_full[..5].to_vec();
let y_sub = y_full[..5].to_vec();
let sigma_sub = vec![1.0; 5];
let model_sub = LinearModel { x: x_sub };
let mut params_sub = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0),
FitParameter::unbounded("b", 1.0),
]);
let result_sub = levenberg_marquardt(
&model_sub,
&y_sub,
&sigma_sub,
&mut params_sub,
&LmConfig::default(),
)
.unwrap();
for j in 0..2 {
assert!(
(result_masked.params[j] - result_sub.params[j]).abs() < 1e-6,
"param {j}: masked={} subset={}",
result_masked.params[j],
result_sub.params[j]
);
}
assert!(
(result_masked.reduced_chi_squared - result_sub.reduced_chi_squared).abs() < 1e-6,
"reduced-χ²: masked={} subset={}",
result_masked.reduced_chi_squared,
result_sub.reduced_chi_squared
);
}
#[test]
fn test_lm_active_mask_excludes_out_of_range_residuals() {
let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
let mut y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
for yi in y.iter_mut().skip(5) {
*yi += 1000.0; }
let sigma = vec![1.0; 10];
let mask = vec![true; 5]
.into_iter()
.chain(std::iter::repeat_n(false, 5))
.collect::<Vec<bool>>();
let model = LinearModel { x };
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0),
FitParameter::unbounded("b", 1.0),
]);
let result = levenberg_marquardt_with_mask(
&model,
&y,
&sigma,
&mut params,
&LmConfig::default(),
Some(&mask),
)
.unwrap();
assert!(result.converged);
assert!(
(result.params[0] - 2.0).abs() < 1e-6,
"slope should be 2.0 (corrupt outliers must be masked out), got {}",
result.params[0]
);
assert!(
(result.params[1] - 3.0).abs() < 1e-6,
"intercept should be 3.0 (corrupt outliers must be masked out), got {}",
result.params[1]
);
}
#[test]
fn test_lm_active_mask_tolerates_nan_outside_range() {
let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
let mut y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
for yi in y.iter_mut().skip(5) {
*yi = f64::NAN;
}
let sigma = vec![1.0; 10];
let mask = vec![true; 5]
.into_iter()
.chain(std::iter::repeat_n(false, 5))
.collect::<Vec<bool>>();
let model = LinearModel { x };
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0),
FitParameter::unbounded("b", 1.0),
]);
let result = levenberg_marquardt_with_mask(
&model,
&y,
&sigma,
&mut params,
&LmConfig::default(),
Some(&mask),
)
.unwrap();
assert!(
result.converged,
"fit should converge despite NaN outside the active mask"
);
assert!(
result.chi_squared.is_finite(),
"χ² should be finite (NaN poisoning prevented), got {}",
result.chi_squared
);
assert!(
(result.params[0] - 2.0).abs() < 1e-6,
"slope should be 2.0, got {}",
result.params[0]
);
assert!(
(result.params[1] - 3.0).abs() < 1e-6,
"intercept should be 3.0, got {}",
result.params[1]
);
}
#[test]
fn test_lm_active_mask_tolerates_model_nan_outside_range() {
struct LinearModelWithMaskedNaN {
x: Vec<f64>,
mask: Vec<bool>,
}
impl FitModel for LinearModelWithMaskedNaN {
fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
let a = params[0];
let b = params[1];
Ok(self
.x
.iter()
.enumerate()
.map(|(i, &x)| if self.mask[i] { a * x + b } else { f64::NAN })
.collect())
}
}
let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
let sigma = vec![1.0; 10];
let mask: Vec<bool> = vec![true; 5]
.into_iter()
.chain(std::iter::repeat_n(false, 5))
.collect();
let model = LinearModelWithMaskedNaN {
x: x.clone(),
mask: mask.clone(),
};
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0),
FitParameter::unbounded("b", 1.0),
]);
let result = levenberg_marquardt_with_mask(
&model,
&y,
&sigma,
&mut params,
&LmConfig::default(),
Some(&mask),
)
.unwrap();
assert!(
result.converged,
"fit should converge despite model NaN at masked bins"
);
assert!(result.chi_squared.is_finite());
assert!((result.params[0] - 2.0).abs() < 1e-6);
assert!((result.params[1] - 3.0).abs() < 1e-6);
}
#[test]
fn test_lm_active_mask_all_false_returns_non_converged() {
let x: Vec<f64> = (0..5).map(|i| i as f64).collect();
let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi + 3.0).collect();
let sigma = vec![1.0; 5];
let mask = vec![false; 5];
let model = LinearModel { x: x.clone() };
let mut params_free = ParameterSet::new(vec![
FitParameter::unbounded("a", 1.0),
FitParameter::unbounded("b", 1.0),
]);
let r_free = levenberg_marquardt_with_mask(
&model,
&y,
&sigma,
&mut params_free,
&LmConfig::default(),
Some(&mask),
)
.unwrap();
assert!(
!r_free.converged,
"n_free > 0 + zero-active mask must NOT report converged"
);
assert!(r_free.chi_squared.is_nan());
assert!(r_free.reduced_chi_squared.is_nan());
let mut params_fixed = ParameterSet::new(vec![
FitParameter::fixed("a", 1.0),
FitParameter::fixed("b", 0.0),
]);
let r_fixed = levenberg_marquardt_with_mask(
&model,
&y,
&sigma,
&mut params_fixed,
&LmConfig::default(),
Some(&mask),
)
.unwrap();
assert!(
!r_fixed.converged,
"n_free == 0 + zero-active mask must NOT report converged \
(sum over zero rows would be 0, masquerading as a perfect fit)"
);
assert!(r_fixed.chi_squared.is_nan());
assert!(r_fixed.reduced_chi_squared.is_nan());
}
#[test]
fn test_compute_jacobian_skips_nan_perturbed_column() {
use crate::parameters::FitParameter;
struct NanInColumn1 {
p1_base: f64,
}
impl FitModel for NanInColumn1 {
fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
let nan_row = (params[1] - self.p1_base).abs() > 1e-12;
let v = if nan_row { f64::NAN } else { params[0] };
Ok(vec![v; 4])
}
}
let model = NanInColumn1 { p1_base: 0.3 };
let mut params = ParameterSet::new(vec![
FitParameter::unbounded("p0", 0.5),
FitParameter::unbounded("p1", 0.3),
]);
let y_current = vec![0.5; 4];
let mut all_vals_buf: Vec<f64> = Vec::new();
let mut free_idx_buf: Vec<usize> = Vec::new();
let jac = compute_jacobian(
&model,
&mut params,
&y_current,
1e-6,
&mut all_vals_buf,
&mut free_idx_buf,
)
.unwrap();
for (i, v) in jac.data.iter().enumerate() {
assert!(
v.is_finite(),
"compute_jacobian produced non-finite entry at index {i}: {v}"
);
}
for i in 0..jac.nrows {
assert_eq!(
jac.get(i, 1),
0.0,
"column 1 (NaN probe) should be zeroed, row {i}"
);
}
}
}