1use nereids_core::constants::{PIVOT_FLOOR, POISSON_EPSILON};
28
29use crate::error::FittingError;
30use crate::lm::{FitModel, FlatMatrix};
31use crate::parameters::{FitParameter, ParameterSet};
32
33#[derive(Debug, Clone)]
35pub struct PoissonConfig {
36 pub max_iter: usize,
38 pub fd_step: f64,
40 pub step_size: f64,
42 pub tol_param: f64,
45 pub armijo_c: f64,
47 pub backtrack: f64,
49 pub gauss_newton_lambda: f64,
51 pub lbfgs_history: usize,
53 pub compute_covariance: bool,
57}
58
59impl Default for PoissonConfig {
60 fn default() -> Self {
61 Self {
62 max_iter: 200,
63 fd_step: 1e-7,
64 step_size: 1.0,
65 tol_param: 1e-8,
66 armijo_c: 1e-4,
67 backtrack: 0.5,
68 gauss_newton_lambda: 1e-3,
69 lbfgs_history: 8,
70 compute_covariance: true,
71 }
72 }
73}
74
75#[derive(Debug, Clone)]
77pub struct PoissonResult {
78 pub nll: f64,
80 pub iterations: usize,
82 pub converged: bool,
84 pub params: Vec<f64>,
86 pub covariance: Option<FlatMatrix>,
96 pub uncertainties: Option<Vec<f64>>,
99}
100
101fn poisson_nll(y_obs: &[f64], y_model: &[f64]) -> f64 {
111 y_obs
112 .iter()
113 .zip(y_model.iter())
114 .map(|(&obs, &mdl)| poisson_nll_term(obs, mdl))
115 .sum()
116}
117
118#[inline]
133fn poisson_nll_term(obs: f64, mdl: f64) -> f64 {
134 debug_assert!(
137 obs.is_finite() && obs >= 0.0,
138 "poisson_nll_term: obs must be finite and >= 0, got {obs}"
139 );
140 if mdl > POISSON_EPSILON {
141 mdl - obs * mdl.ln()
142 } else {
143 let eps = POISSON_EPSILON;
144 let nll_eps = eps - obs * eps.ln();
145 let grad_eps = 1.0 - obs / eps;
146 let hess_eps = if obs > 0.0 {
149 obs / (eps * eps)
150 } else {
151 1.0 / eps
152 };
153 let delta = eps - mdl;
154 nll_eps - grad_eps * delta + 0.5 * hess_eps * delta * delta
157 }
158}
159
160#[inline]
166fn poisson_nll_weight(obs: f64, mdl: f64) -> f64 {
167 if mdl > POISSON_EPSILON {
168 1.0 - obs / mdl
169 } else {
170 let eps = POISSON_EPSILON;
171 let grad_eps = 1.0 - obs / eps;
172 let hess_eps = if obs > 0.0 {
173 obs / (eps * eps)
174 } else {
175 1.0 / eps
176 };
177 grad_eps - hess_eps * (eps - mdl)
178 }
179}
180
181#[inline]
186fn poisson_nll_curvature(obs: f64, mdl: f64) -> f64 {
187 if mdl > POISSON_EPSILON {
188 obs / (mdl * mdl)
189 } else {
190 let eps = POISSON_EPSILON;
191 if obs > 0.0 {
192 obs / (eps * eps)
193 } else {
194 1.0 / eps
195 }
196 }
197}
198
199#[derive(Debug)]
201struct AnalyticalStepData {
202 grad: Vec<f64>,
204 fisher: FlatMatrix,
206}
207
208fn compute_analytical_step_data(
221 model: &dyn FitModel,
222 params: &ParameterSet,
223 y_obs: &[f64],
224 y_model: &[f64],
225 all_vals_buf: &mut Vec<f64>,
226 free_idx_buf: &mut Vec<usize>,
227) -> Option<AnalyticalStepData> {
228 params.all_values_into(all_vals_buf);
229 params.free_indices_into(free_idx_buf);
230 let jac = model.analytical_jacobian(all_vals_buf, free_idx_buf, y_model)?;
231 let n_e = y_obs.len();
232 let n_free = free_idx_buf.len();
233 let mut grad = vec![0.0f64; n_free];
234 let mut fisher = FlatMatrix::zeros(n_free, n_free);
235 for i in 0..n_e {
236 let w = poisson_nll_weight(y_obs[i], y_model[i]);
237 let h = poisson_nll_curvature(y_obs[i], y_model[i]);
238 for (g, j) in grad.iter_mut().zip(0..n_free) {
239 let jij = jac.get(i, j);
240 *g += w * jij;
241 for k in 0..n_free {
242 *fisher.get_mut(j, k) += h * jij * jac.get(i, k);
243 }
244 }
245 }
246 Some(AnalyticalStepData { grad, fisher })
247}
248
249fn compute_gradient(
258 model: &dyn FitModel,
259 params: &mut ParameterSet,
260 y_obs: &[f64],
261 fd_step: f64,
262 all_vals_buf: &mut Vec<f64>,
263 free_idx_buf: &mut Vec<usize>,
264) -> Result<Vec<f64>, FittingError> {
265 params.all_values_into(all_vals_buf);
266 let base_model = model.evaluate(all_vals_buf)?;
267 let base_nll = poisson_nll(y_obs, &base_model);
268
269 params.free_indices_into(free_idx_buf);
270 let mut grad = vec![0.0; free_idx_buf.len()];
271
272 for (j, &idx) in free_idx_buf.iter().enumerate() {
273 let original = params.params[idx].value;
274 let step = fd_step * (1.0 + original.abs());
275
276 params.params[idx].value = original + step;
277 params.params[idx].clamp();
278 let mut actual_step = params.params[idx].value - original;
279
280 if actual_step.abs() < PIVOT_FLOOR {
283 params.params[idx].value = original - step;
284 params.params[idx].clamp();
285 actual_step = params.params[idx].value - original;
286 if actual_step.abs() < PIVOT_FLOOR {
287 params.params[idx].value = original;
289 continue;
290 }
291 }
292
293 params.all_values_into(all_vals_buf);
294 let perturbed_model = match model.evaluate(all_vals_buf) {
295 Ok(v) => v,
296 Err(_) => {
297 params.params[idx].value = original;
298 continue;
299 }
300 };
301 let perturbed_nll = poisson_nll(y_obs, &perturbed_model);
302 params.params[idx].value = original;
303
304 grad[j] = (perturbed_nll - base_nll) / actual_step;
305 }
306
307 Ok(grad)
308}
309
310fn normalized_step_norm(
311 old_free: &[f64],
312 new_free: &[f64],
313 params: &ParameterSet,
314 free_param_indices: &[usize],
315) -> f64 {
316 old_free
317 .iter()
318 .zip(new_free.iter())
319 .zip(free_param_indices.iter())
320 .map(|((&old, &new), &idx)| {
321 let range = params.params[idx].upper - params.params[idx].lower;
322 let scale = if range.is_finite() && range > 1e-10 {
323 range
324 } else {
325 old.abs().max(1e-3)
326 };
327 ((old - new) / scale).powi(2)
328 })
329 .sum::<f64>()
330 .sqrt()
331}
332
333fn is_bound_active(param: &FitParameter, grad: f64) -> bool {
334 let at_lower = param.lower.is_finite() && (param.value - param.lower).abs() <= PIVOT_FLOOR;
335 let at_upper = param.upper.is_finite() && (param.value - param.upper).abs() <= PIVOT_FLOOR;
336 (at_lower && grad > 0.0) || (at_upper && grad < 0.0)
337}
338
339fn inactive_free_positions(
340 params: &ParameterSet,
341 free_param_indices: &[usize],
342 grad: &[f64],
343) -> Vec<usize> {
344 free_param_indices
345 .iter()
346 .zip(grad.iter())
347 .enumerate()
348 .filter_map(|(pos, (&idx, &g))| (!is_bound_active(¶ms.params[idx], g)).then_some(pos))
349 .collect()
350}
351
352fn inactive_free_mask(
353 params: &ParameterSet,
354 free_param_indices: &[usize],
355 grad: &[f64],
356) -> Vec<bool> {
357 free_param_indices
358 .iter()
359 .zip(grad.iter())
360 .map(|(&idx, &g)| !is_bound_active(¶ms.params[idx], g))
361 .collect()
362}
363
364fn projected_gradient_norm(
365 params: &ParameterSet,
366 free_param_indices: &[usize],
367 grad: &[f64],
368) -> f64 {
369 free_param_indices
370 .iter()
371 .zip(grad.iter())
372 .map(|(&idx, &g)| {
373 if is_bound_active(¶ms.params[idx], g) {
374 0.0
375 } else {
376 g * g
377 }
378 })
379 .sum::<f64>()
380 .sqrt()
381}
382
383fn extract_submatrix(matrix: &FlatMatrix, positions: &[usize]) -> FlatMatrix {
384 let n = positions.len();
385 let mut sub = FlatMatrix::zeros(n, n);
386 for (row_out, &row_in) in positions.iter().enumerate() {
387 for (col_out, &col_in) in positions.iter().enumerate() {
388 *sub.get_mut(row_out, col_out) = matrix.get(row_in, col_in);
389 }
390 }
391 sub
392}
393
394#[derive(Debug, Clone)]
395struct LbfgsHistory {
396 s_list: Vec<Vec<f64>>,
397 y_list: Vec<Vec<f64>>,
398 max_pairs: usize,
399}
400
401impl LbfgsHistory {
402 fn new(max_pairs: usize) -> Self {
403 Self {
404 s_list: Vec::with_capacity(max_pairs),
405 y_list: Vec::with_capacity(max_pairs),
406 max_pairs,
407 }
408 }
409
410 fn clear(&mut self) {
411 self.s_list.clear();
412 self.y_list.clear();
413 }
414
415 fn update(&mut self, old_free: &[f64], new_free: &[f64], old_grad: &[f64], new_grad: &[f64]) {
416 if self.max_pairs == 0 {
417 return;
418 }
419 let s: Vec<f64> = new_free
420 .iter()
421 .zip(old_free.iter())
422 .map(|(&new, &old)| new - old)
423 .collect();
424 let y: Vec<f64> = new_grad
425 .iter()
426 .zip(old_grad.iter())
427 .map(|(&new, &old)| new - old)
428 .collect();
429 let sy = dot(&s, &y);
430 let s_norm = dot(&s, &s).sqrt();
431 let y_norm = dot(&y, &y).sqrt();
432 if sy <= 1e-12 * s_norm * y_norm.max(1.0) {
433 return;
434 }
435 if self.s_list.len() == self.max_pairs {
436 self.s_list.remove(0);
437 self.y_list.remove(0);
438 }
439 self.s_list.push(s);
440 self.y_list.push(y);
441 }
442
443 fn apply_on_positions(&self, grad: &[f64], positions: &[usize]) -> Option<Vec<f64>> {
444 if self.s_list.is_empty() || positions.is_empty() {
445 return None;
446 }
447
448 let mut q: Vec<f64> = positions.iter().map(|&pos| grad[pos]).collect();
449 let mut alpha = vec![0.0; self.s_list.len()];
450 let mut rho = vec![0.0; self.s_list.len()];
451 let mut used = vec![false; self.s_list.len()];
452
453 for i in (0..self.s_list.len()).rev() {
454 let s_sub = extract_positions(&self.s_list[i], positions);
455 let y_sub = extract_positions(&self.y_list[i], positions);
456 let sy = dot(&s_sub, &y_sub);
457 if sy <= 1e-12 {
458 continue;
459 }
460 rho[i] = 1.0 / sy;
461 used[i] = true;
462 alpha[i] = rho[i] * dot(&s_sub, &q);
463 axpy(&mut q, -alpha[i], &y_sub);
464 }
465
466 let gamma = used
467 .iter()
468 .enumerate()
469 .rev()
470 .find_map(|(i, &is_used)| {
471 if !is_used {
472 return None;
473 }
474 let last_s = extract_positions(&self.s_list[i], positions);
475 let last_y = extract_positions(&self.y_list[i], positions);
476 let yy = dot(&last_y, &last_y);
477 (yy > 0.0).then_some(dot(&last_s, &last_y) / yy)
478 })
479 .unwrap_or(1.0);
480
481 let mut r: Vec<f64> = q.into_iter().map(|v| gamma * v).collect();
482 for i in 0..self.s_list.len() {
483 if !used[i] {
484 continue;
485 }
486 let s_sub = extract_positions(&self.s_list[i], positions);
487 let y_sub = extract_positions(&self.y_list[i], positions);
488 let beta = rho[i] * dot(&y_sub, &r);
489 axpy(&mut r, alpha[i] - beta, &s_sub);
490 }
491
492 let mut full = vec![0.0; grad.len()];
493 for (&pos, &value) in positions.iter().zip(r.iter()) {
494 full[pos] = value;
495 }
496 Some(full)
497 }
498}
499
500fn dot(a: &[f64], b: &[f64]) -> f64 {
501 a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum()
502}
503
504fn axpy(dst: &mut [f64], alpha: f64, src: &[f64]) {
505 for (d, &s) in dst.iter_mut().zip(src.iter()) {
506 *d += alpha * s;
507 }
508}
509
510fn extract_positions(values: &[f64], positions: &[usize]) -> Vec<f64> {
511 positions.iter().map(|&pos| values[pos]).collect()
512}
513
514fn parameter_scaled_gradient_direction(
515 params: &ParameterSet,
516 free_param_indices: &[usize],
517 grad: &[f64],
518) -> Vec<f64> {
519 grad.iter()
520 .zip(free_param_indices.iter())
521 .map(|(&g, &idx)| {
522 if is_bound_active(¶ms.params[idx], g) {
523 return 0.0;
524 }
525 let p = ¶ms.params[idx];
526 let range = p.upper - p.lower;
527 if range.is_finite() && range > 1e-10 {
528 g * range * range
529 } else {
530 let scale = p.value.abs().max(1e-3);
531 g * scale * scale
532 }
533 })
534 .collect()
535}
536
537fn max_feasible_step(
538 params: &ParameterSet,
539 free_param_indices: &[usize],
540 old_free: &[f64],
541 search_dir: &[f64],
542) -> f64 {
543 let mut alpha_max = f64::INFINITY;
544 for ((&idx, &x), &d) in free_param_indices
545 .iter()
546 .zip(old_free.iter())
547 .zip(search_dir.iter())
548 {
549 if d.abs() <= PIVOT_FLOOR {
550 continue;
551 }
552 let p = ¶ms.params[idx];
553 let candidate = if d > 0.0 && p.lower.is_finite() {
554 (x - p.lower) / d
555 } else if d < 0.0 && p.upper.is_finite() {
556 (p.upper - x) / (-d)
557 } else {
558 f64::INFINITY
559 };
560 alpha_max = alpha_max.min(candidate);
561 }
562 alpha_max.max(0.0)
563}
564
565enum LineSearchResult {
566 Accepted {
567 nll: f64,
568 y_model: Vec<f64>,
569 hit_boundary: bool,
570 },
571 Stagnated,
572 Failed,
573}
574
575const MAX_FACE_STEPS_PER_ITER: usize = 4;
576
577#[allow(clippy::too_many_arguments)]
611fn backtracking_line_search(
612 model: &dyn FitModel,
613 params: &mut ParameterSet,
614 y_obs: &[f64],
615 old_free: &[f64],
616 free_param_indices: &[usize],
617 search_dir: &[f64],
618 initial_alpha: f64,
619 config: &PoissonConfig,
620 grad: &[f64],
621 nll: f64,
622 all_vals_buf: &mut Vec<f64>,
623 free_vals_buf: &mut Vec<f64>,
624 trial_free_buf: &mut Vec<f64>,
625) -> LineSearchResult {
626 let alpha_max = max_feasible_step(params, free_param_indices, old_free, search_dir);
627 if alpha_max <= PIVOT_FLOOR {
628 params.set_free_values(old_free);
629 return LineSearchResult::Stagnated;
630 }
631 let mut alpha = initial_alpha.min(alpha_max);
632 for _ in 0..50 {
633 trial_free_buf.clear();
637 for ((&idx, &v), &d) in free_param_indices
638 .iter()
639 .zip(old_free.iter())
640 .zip(search_dir.iter())
641 {
642 let p = ¶ms.params[idx];
643 trial_free_buf.push((v - alpha * d).clamp(p.lower, p.upper));
644 }
645 params.set_free_values(trial_free_buf);
646
647 params.all_values_into(all_vals_buf);
648 let trial_model = match model.evaluate(all_vals_buf) {
649 Ok(v) => v,
650 Err(_) => {
651 alpha *= config.backtrack;
652 continue;
653 }
654 };
655
656 if trial_model.iter().any(|v| !v.is_finite()) {
659 alpha *= config.backtrack;
660 continue;
661 }
662
663 let trial_nll = poisson_nll(y_obs, &trial_model);
664
665 params.free_values_into(free_vals_buf);
667 let step_norm = normalized_step_norm(old_free, free_vals_buf, params, free_param_indices);
668 let descent = grad
669 .iter()
670 .zip(old_free.iter())
671 .zip(free_vals_buf.iter())
672 .map(|((&g, &old), &new)| g * (old - new))
673 .sum::<f64>();
674
675 if trial_nll.is_finite() && trial_nll <= nll - config.armijo_c * descent {
676 return LineSearchResult::Accepted {
677 nll: trial_nll,
678 y_model: trial_model,
679 hit_boundary: alpha_max.is_finite()
680 && (alpha_max - alpha).abs() <= 1e-12 * alpha_max.max(1.0),
681 };
682 }
683
684 let nll_delta = (trial_nll - nll).abs();
685 let nll_scale = trial_nll.abs().max(nll.abs()).max(1.0);
686 if trial_nll.is_finite()
687 && step_norm < config.tol_param
688 && nll_delta <= config.tol_param * nll_scale
689 {
690 params.set_free_values(old_free);
691 return LineSearchResult::Stagnated;
692 }
693
694 alpha *= config.backtrack;
696 if alpha <= PIVOT_FLOOR {
697 break;
698 }
699 }
700 params.set_free_values(old_free);
701 LineSearchResult::Failed
702}
703
704fn try_early_return_fixed(
711 model: &dyn FitModel,
712 y_obs: &[f64],
713 params: &ParameterSet,
714) -> Result<Option<PoissonResult>, FittingError> {
715 if params.n_free() != 0 {
716 return Ok(None);
717 }
718 let y_model = model.evaluate(¶ms.all_values())?;
719 let nll = poisson_nll(y_obs, &y_model);
720 if !nll.is_finite() {
721 return Ok(Some(PoissonResult {
722 nll,
723 iterations: 0,
724 converged: false,
725 params: params.all_values(),
726 covariance: None,
727 uncertainties: None,
728 }));
729 }
730 Ok(Some(PoissonResult {
731 nll,
732 iterations: 0,
733 converged: true,
734 params: params.all_values(),
735 covariance: None,
736 uncertainties: None,
737 }))
738}
739
740fn compute_fd_fisher(
750 model: &dyn FitModel,
751 params: &mut ParameterSet,
752 y_obs: &[f64],
753 y_model: &[f64],
754 fd_step: f64,
755 all_vals_buf: &mut Vec<f64>,
756 free_idx_buf: &mut Vec<usize>,
757) -> Option<FlatMatrix> {
758 params.free_indices_into(free_idx_buf);
759 let n_free = free_idx_buf.len();
760 let n_e = y_obs.len();
761
762 let mut jac = FlatMatrix::zeros(n_e, n_free);
763 for (col, &fi) in free_idx_buf.iter().enumerate() {
764 let orig = params.params[fi].value;
765 let h = fd_step * (1.0 + orig.abs());
766
767 params.params[fi].value = orig + h;
769 params.params[fi].clamp();
770 let step_plus = params.params[fi].value - orig;
771
772 let y_plus = if step_plus.abs() > PIVOT_FLOOR {
773 params.all_values_into(all_vals_buf);
774 let y = match model.evaluate(all_vals_buf) {
775 Ok(v) => v,
776 Err(_) => {
777 params.params[fi].value = orig;
778 return None;
779 }
780 };
781 Some(y)
782 } else {
783 None
784 };
785
786 params.params[fi].value = orig - h;
788 params.params[fi].clamp();
789 let step_minus = params.params[fi].value - orig;
790
791 let y_minus = if step_minus.abs() > PIVOT_FLOOR {
792 params.all_values_into(all_vals_buf);
793 let y = match model.evaluate(all_vals_buf) {
794 Ok(v) => v,
795 Err(_) => {
796 params.params[fi].value = orig;
797 return None;
798 }
799 };
800 Some(y)
801 } else {
802 None
803 };
804
805 params.params[fi].value = orig;
806
807 match (&y_plus, &y_minus) {
809 (Some(yp), Some(ym)) => {
810 let denom = step_plus - step_minus;
811 for (i, (&vp, &vm)) in yp.iter().zip(ym.iter()).enumerate() {
812 *jac.get_mut(i, col) = (vp - vm) / denom;
813 }
814 }
815 (Some(yp), None) => {
816 for (i, (&vp, &v0)) in yp.iter().zip(y_model.iter()).enumerate() {
817 *jac.get_mut(i, col) = (vp - v0) / step_plus;
818 }
819 }
820 (None, Some(ym)) => {
821 for (i, (&v0, &vm)) in y_model.iter().zip(ym.iter()).enumerate() {
822 *jac.get_mut(i, col) = (v0 - vm) / (-step_minus);
823 }
824 }
825 (None, None) => {
826 }
828 }
829 }
830
831 let mut fisher = FlatMatrix::zeros(n_free, n_free);
832 for i in 0..n_e {
833 let h_i = poisson_nll_curvature(y_obs[i], y_model[i]);
834 for j in 0..n_free {
835 let jij = jac.get(i, j);
836 for k in 0..n_free {
837 *fisher.get_mut(j, k) += h_i * jij * jac.get(i, k);
838 }
839 }
840 }
841 Some(fisher)
842}
843
844pub fn poisson_fit(
863 model: &dyn FitModel,
864 y_obs: &[f64],
865 params: &mut ParameterSet,
866 config: &PoissonConfig,
867) -> Result<PoissonResult, FittingError> {
868 if let Some(result) = try_early_return_fixed(model, y_obs, params)? {
869 return Ok(result);
870 }
871
872 let mut all_vals_buf = Vec::with_capacity(params.params.len());
876 let mut free_vals_buf = Vec::with_capacity(params.n_free());
877 let mut old_free_buf: Vec<f64> = Vec::with_capacity(params.n_free());
878 let mut trial_free_buf: Vec<f64> = Vec::with_capacity(params.n_free());
879 let mut free_idx_buf: Vec<usize> = Vec::with_capacity(params.n_free());
880 let mut fd_history = LbfgsHistory::new(config.lbfgs_history);
881 let mut pending_fd_state: Option<(Vec<f64>, Vec<f64>, Vec<bool>)> = None;
882
883 params.all_values_into(&mut all_vals_buf);
884 let mut y_model = model.evaluate(&all_vals_buf)?;
885 let mut nll = poisson_nll(y_obs, &y_model);
886
887 if !nll.is_finite() {
890 return Ok(PoissonResult {
891 nll,
892 iterations: 0,
893 converged: false,
894 params: params.all_values(),
895 covariance: None,
896 uncertainties: None,
897 });
898 }
899
900 let mut converged = false;
901 let mut iter = 0;
902
903 'outer: for _ in 0..config.max_iter {
904 iter += 1;
905 let mut face_steps = 0usize;
906
907 loop {
908 let analytical_step = compute_analytical_step_data(
912 model,
913 params,
914 y_obs,
915 &y_model,
916 &mut all_vals_buf,
917 &mut free_idx_buf,
918 );
919 let grad = if let Some(ref analytical) = analytical_step {
920 analytical.grad.clone()
921 } else {
922 compute_gradient(
923 model,
924 params,
925 y_obs,
926 config.fd_step,
927 &mut all_vals_buf,
928 &mut free_idx_buf,
929 )?
930 };
931
932 params.free_indices_into(&mut free_idx_buf);
933
934 let using_fd = analytical_step.is_none();
935 if using_fd {
936 params.free_values_into(&mut free_vals_buf);
937 let current_mask = inactive_free_mask(params, &free_idx_buf, &grad);
938 if let Some((prev_free, prev_grad, prev_mask)) = pending_fd_state.take() {
939 if prev_mask == current_mask {
940 fd_history.update(&prev_free, &free_vals_buf, &prev_grad, &grad);
941 } else {
942 fd_history.clear();
943 }
944 }
945 pending_fd_state = Some((free_vals_buf.clone(), grad.clone(), current_mask));
946 } else {
947 pending_fd_state.take();
948 fd_history.clear();
949 }
950
951 let projected_grad_norm = projected_gradient_norm(params, &free_idx_buf, &grad);
953 if projected_grad_norm < config.tol_param {
954 converged = true;
955 break 'outer;
956 }
957
958 let (search_dir, initial_alpha): (Vec<f64>, f64) =
959 if let Some(ref analytical) = analytical_step {
960 let inactive_positions = inactive_free_positions(params, &free_idx_buf, &grad);
961 if inactive_positions.is_empty() {
962 converged = true;
963 break 'outer;
964 }
965 let reduced_fisher = extract_submatrix(&analytical.fisher, &inactive_positions);
966 let reduced_grad: Vec<f64> =
967 inactive_positions.iter().map(|&pos| grad[pos]).collect();
968 let reduced_dir = crate::lm::solve_damped_system(
969 &reduced_fisher,
970 &reduced_grad,
971 config.gauss_newton_lambda,
972 )
973 .unwrap_or_else(|| {
974 reduced_grad
975 .iter()
976 .enumerate()
977 .map(|(j, &g)| g / reduced_fisher.get(j, j).max(1e-12))
978 .collect()
979 });
980 let mut dir = vec![0.0; grad.len()];
981 for (&pos, &value) in inactive_positions.iter().zip(reduced_dir.iter()) {
982 dir[pos] = value;
983 }
984 (dir, config.step_size)
985 } else {
986 let inactive_positions = inactive_free_positions(params, &free_idx_buf, &grad);
987 if inactive_positions.is_empty() {
988 converged = true;
989 break 'outer;
990 }
991 let used_history = !fd_history.s_list.is_empty();
992 let mut dir = fd_history
993 .apply_on_positions(&grad, &inactive_positions)
994 .unwrap_or_else(|| {
995 parameter_scaled_gradient_direction(params, &free_idx_buf, &grad)
996 });
997 let descent = dot(&grad, &dir);
998 if !descent.is_finite() || descent <= 0.0 {
999 dir = parameter_scaled_gradient_direction(params, &free_idx_buf, &grad);
1000 }
1001 if used_history && descent.is_finite() && descent > 0.0 {
1002 (dir, config.step_size)
1003 } else {
1004 let search_norm: f64 = dir.iter().map(|d| d * d).sum::<f64>().sqrt();
1005 (dir, config.step_size / search_norm.max(1.0))
1006 }
1007 };
1008
1009 params.free_values_into(&mut free_vals_buf);
1010 old_free_buf.clear();
1011 old_free_buf.extend_from_slice(&free_vals_buf);
1012
1013 match backtracking_line_search(
1014 model,
1015 params,
1016 y_obs,
1017 &old_free_buf,
1018 &free_idx_buf,
1019 &search_dir,
1020 initial_alpha,
1021 config,
1022 &grad,
1023 nll,
1024 &mut all_vals_buf,
1025 &mut free_vals_buf,
1026 &mut trial_free_buf,
1027 ) {
1028 LineSearchResult::Accepted {
1029 nll: new_nll,
1030 y_model: new_y_model,
1031 hit_boundary,
1032 } => {
1033 if !using_fd {
1034 pending_fd_state = None;
1035 }
1036 nll = new_nll;
1037 y_model = new_y_model;
1038
1039 if hit_boundary && face_steps < MAX_FACE_STEPS_PER_ITER {
1040 face_steps += 1;
1041 continue;
1042 }
1043 }
1044 LineSearchResult::Stagnated => {
1045 converged = true;
1046 break 'outer;
1047 }
1048 LineSearchResult::Failed => {
1049 break 'outer;
1050 }
1051 }
1052
1053 params.free_values_into(&mut free_vals_buf);
1054 let step_norm =
1055 normalized_step_norm(&old_free_buf, &free_vals_buf, params, &free_idx_buf);
1056 if step_norm < config.tol_param {
1057 converged = true;
1058 break 'outer;
1059 }
1060
1061 break;
1062 }
1063 }
1064
1065 let (covariance, uncertainties) = if converged && config.compute_covariance {
1071 let fisher_opt = if let Some(step_data) = compute_analytical_step_data(
1079 model,
1080 params,
1081 y_obs,
1082 &y_model,
1083 &mut all_vals_buf,
1084 &mut free_idx_buf,
1085 ) {
1086 Some(step_data.fisher)
1087 } else {
1088 compute_fd_fisher(
1090 model,
1091 params,
1092 y_obs,
1093 &y_model,
1094 config.fd_step,
1095 &mut all_vals_buf,
1096 &mut free_idx_buf,
1097 )
1098 };
1099
1100 if let Some(fisher) = fisher_opt {
1101 if let Some(cov) = crate::lm::invert_matrix(&fisher) {
1102 let n_free = cov.nrows;
1114 let unc: Vec<f64> = (0..n_free)
1115 .map(|i| {
1116 let d = cov.get(i, i);
1117 if d.is_finite() && d > 0.0 {
1118 d.sqrt()
1119 } else {
1120 f64::NAN
1121 }
1122 })
1123 .collect();
1124 (Some(cov), Some(unc))
1125 } else {
1126 (None, None)
1127 }
1128 } else {
1129 (None, None)
1130 }
1131 } else {
1132 (None, None)
1133 };
1134
1135 Ok(PoissonResult {
1136 nll,
1137 iterations: iter,
1138 converged,
1139 params: params.all_values(),
1140 covariance,
1141 uncertainties,
1142 })
1143}
1144
1145pub struct CountsModel<'a> {
1165 pub transmission_model: &'a dyn FitModel,
1167 pub flux: &'a [f64],
1169 pub background: &'a [f64],
1171 pub n_params: usize,
1173}
1174
1175impl<'a> FitModel for CountsModel<'a> {
1176 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1177 let transmission = self.transmission_model.evaluate(params)?;
1178 debug_assert_eq!(
1179 transmission.len(),
1180 self.flux.len(),
1181 "CountsModel: transmission length ({}) != flux length ({})",
1182 transmission.len(),
1183 self.flux.len(),
1184 );
1185 debug_assert_eq!(
1186 self.flux.len(),
1187 self.background.len(),
1188 "CountsModel: flux length ({}) != background length ({})",
1189 self.flux.len(),
1190 self.background.len(),
1191 );
1192 Ok(transmission
1193 .iter()
1194 .zip(self.flux.iter())
1195 .zip(self.background.iter())
1196 .map(|((&t, &f), &b)| f * t + b)
1197 .collect())
1198 }
1199
1200 fn analytical_jacobian(
1204 &self,
1205 params: &[f64],
1206 free_param_indices: &[usize],
1207 y_current: &[f64],
1208 ) -> Option<FlatMatrix> {
1209 let n_e = y_current.len();
1210 let t_inner: Vec<f64> = y_current
1212 .iter()
1213 .zip(self.flux.iter())
1214 .zip(self.background.iter())
1215 .map(|((&y, &f), &b)| if f.abs() > 1e-30 { (y - b) / f } else { 0.0 })
1216 .collect();
1217 let inner_jac =
1218 self.transmission_model
1219 .analytical_jacobian(params, free_param_indices, &t_inner)?;
1220 let n_free = free_param_indices.len();
1221 let mut jac = FlatMatrix::zeros(n_e, n_free);
1222 for i in 0..n_e {
1223 for j in 0..n_free {
1224 *jac.get_mut(i, j) = self.flux[i] * inner_jac.get(i, j);
1225 }
1226 }
1227 Some(jac)
1228 }
1229}
1230
1231impl<'a> crate::forward_model::ForwardModel for CountsModel<'a> {
1234 fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1235 self.evaluate(params)
1236 }
1237
1238 fn n_data(&self) -> usize {
1241 self.flux.len()
1242 }
1243
1244 fn n_params(&self) -> usize {
1245 self.n_params
1246 }
1247}
1248
1249pub struct CountsBackgroundScaleModel<'a> {
1275 pub transmission_model: &'a dyn FitModel,
1277 pub flux: &'a [f64],
1279 pub background: &'a [f64],
1281 pub alpha1_index: usize,
1284 pub alpha2_index: usize,
1287 pub n_params: usize,
1289}
1290
1291impl<'a> FitModel for CountsBackgroundScaleModel<'a> {
1292 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1293 let transmission = self.transmission_model.evaluate(params)?;
1294 let alpha1 = params[self.alpha1_index];
1295 let alpha2 = params[self.alpha2_index];
1296 debug_assert_eq!(transmission.len(), self.flux.len());
1297 debug_assert_eq!(self.flux.len(), self.background.len());
1298 Ok(transmission
1299 .iter()
1300 .zip(self.flux.iter())
1301 .zip(self.background.iter())
1302 .map(|((&t, &f), &b)| alpha1 * f * t + alpha2 * b)
1303 .collect())
1304 }
1305
1306 fn analytical_jacobian(
1307 &self,
1308 params: &[f64],
1309 free_param_indices: &[usize],
1310 y_current: &[f64],
1311 ) -> Option<FlatMatrix> {
1312 let n_e = y_current.len();
1313 let n_free = free_param_indices.len();
1314 let alpha1 = params[self.alpha1_index];
1315 let alpha1_col = free_param_indices
1316 .iter()
1317 .position(|&i| i == self.alpha1_index);
1318 let alpha2_col = free_param_indices
1319 .iter()
1320 .position(|&i| i == self.alpha2_index);
1321 let inner_free: Vec<usize> = free_param_indices
1322 .iter()
1323 .copied()
1324 .filter(|&i| i != self.alpha1_index && i != self.alpha2_index)
1325 .collect();
1326
1327 let t_inner = match self.transmission_model.evaluate(params) {
1331 Ok(t) => t,
1332 Err(_) => return None,
1333 };
1334
1335 let inner_jac = if !inner_free.is_empty() {
1336 self.transmission_model
1337 .analytical_jacobian(params, &inner_free, &t_inner)
1338 } else {
1339 None
1340 };
1341
1342 let mut jacobian = FlatMatrix::zeros(n_e, n_free);
1343 if let Some(ref ij) = inner_jac {
1344 let mut inner_col = 0;
1345 for (col, &fp) in free_param_indices.iter().enumerate() {
1346 if fp == self.alpha1_index || fp == self.alpha2_index {
1347 continue;
1348 }
1349 for row in 0..n_e {
1350 *jacobian.get_mut(row, col) = alpha1 * self.flux[row] * ij.get(row, inner_col);
1351 }
1352 inner_col += 1;
1353 }
1354 } else if !inner_free.is_empty() {
1355 return None;
1356 }
1357
1358 if let Some(col) = alpha1_col {
1365 for (row, (&f, &t)) in self.flux.iter().zip(t_inner.iter()).enumerate() {
1366 *jacobian.get_mut(row, col) += f * t;
1367 }
1368 }
1369 if let Some(col) = alpha2_col {
1370 for (row, &bg) in self.background.iter().enumerate() {
1371 *jacobian.get_mut(row, col) += bg;
1372 }
1373 }
1374
1375 Some(jacobian)
1376 }
1377}
1378
1379impl<'a> crate::forward_model::ForwardModel for CountsBackgroundScaleModel<'a> {
1380 fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1381 self.evaluate(params)
1382 }
1383
1384 fn n_data(&self) -> usize {
1385 self.flux.len()
1386 }
1387
1388 fn n_params(&self) -> usize {
1389 self.n_params
1390 }
1391}
1392
1393pub struct TransmissionKLBackgroundModel<'a> {
1428 pub inner: &'a dyn FitModel,
1430 pub inv_sqrt_energies: Vec<f64>,
1432 pub b0_index: usize,
1435 pub b1_index: usize,
1438 pub n_params: usize,
1440}
1441
1442impl<'a> FitModel for TransmissionKLBackgroundModel<'a> {
1443 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1444 let t_inner = self.inner.evaluate(params)?;
1445 let b0 = params[self.b0_index];
1446 let b1 = params[self.b1_index];
1447 Ok(t_inner
1448 .iter()
1449 .zip(self.inv_sqrt_energies.iter())
1450 .map(|(&t, &inv_sqrt_e)| t + b0 + b1 * inv_sqrt_e)
1451 .collect())
1452 }
1453
1454 fn analytical_jacobian(
1455 &self,
1456 params: &[f64],
1457 free_param_indices: &[usize],
1458 y_current: &[f64],
1459 ) -> Option<FlatMatrix> {
1460 let n_e = y_current.len();
1461 let n_free = free_param_indices.len();
1462
1463 let b0_col = free_param_indices.iter().position(|&i| i == self.b0_index);
1465 let b1_col = free_param_indices.iter().position(|&i| i == self.b1_index);
1466
1467 let inner_free: Vec<usize> = free_param_indices
1469 .iter()
1470 .copied()
1471 .filter(|&i| i != self.b0_index && i != self.b1_index)
1472 .collect();
1473
1474 let inner_jac = if !inner_free.is_empty() {
1476 let t_inner = self.inner.evaluate(params).ok()?;
1478 self.inner
1479 .analytical_jacobian(params, &inner_free, &t_inner)
1480 } else {
1481 None
1482 };
1483
1484 let mut jacobian = FlatMatrix::zeros(n_e, n_free);
1485
1486 if let Some(ij) = inner_jac.as_ref() {
1490 let mut inner_col = 0;
1491 for (col, &fp) in free_param_indices.iter().enumerate() {
1492 if fp == self.b0_index || fp == self.b1_index {
1493 continue;
1494 }
1495 for row in 0..n_e {
1496 *jacobian.get_mut(row, col) = ij.get(row, inner_col);
1497 }
1498 inner_col += 1;
1499 }
1500 } else if !inner_free.is_empty() {
1501 return None;
1504 }
1505
1506 if let Some(col) = b0_col {
1513 for row in 0..n_e {
1514 *jacobian.get_mut(row, col) += 1.0; }
1516 }
1517 if let Some(col) = b1_col {
1518 for row in 0..n_e {
1519 *jacobian.get_mut(row, col) += self.inv_sqrt_energies[row]; }
1521 }
1522
1523 Some(jacobian)
1524 }
1525}
1526
1527impl<'a> crate::forward_model::ForwardModel for TransmissionKLBackgroundModel<'a> {
1528 fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1529 self.evaluate(params)
1530 }
1531
1532 fn n_data(&self) -> usize {
1533 self.inv_sqrt_energies.len()
1534 }
1535
1536 fn n_params(&self) -> usize {
1537 self.n_params
1538 }
1539}
1540
1541#[cfg(test)]
1542mod tests {
1543 use super::*;
1544 use crate::lm::FitModel;
1545 use crate::parameters::FitParameter;
1546
1547 fn poisson_deviance(y_obs: &[f64], y_model: &[f64]) -> f64 {
1564 debug_assert!(
1567 y_obs.iter().all(|&o| o >= 0.0),
1568 "poisson_deviance requires non-negative observations"
1569 );
1570 y_obs
1571 .iter()
1572 .zip(y_model.iter())
1573 .map(|(&obs, &mdl)| {
1574 let m = mdl.max(POISSON_EPSILON);
1575 if obs > 0.0 {
1576 2.0 * (obs * (obs / m).ln() - (obs - m))
1577 } else {
1578 2.0 * m
1579 }
1580 })
1581 .sum()
1582 }
1583
1584 #[test]
1595 fn test_poisson_deviance_hand_computed() {
1596 let y_obs = [4.0, 1.0, 0.0, 3.0];
1597 let y_model = [2.0, 1.0, 5.0, 0.0];
1598
1599 let eps = POISSON_EPSILON;
1600 let term_main = 8.0 * 2.0_f64.ln() - 4.0; let term_match = 0.0; let term_zero_obs = 10.0; let term_floored = 2.0 * (3.0 * (3.0 / eps).ln() - (3.0 - eps)); let expected = term_main + term_match + term_zero_obs + term_floored;
1605
1606 let got = poisson_deviance(&y_obs, &y_model);
1607 assert!(
1608 (got - expected).abs() < 1e-9,
1609 "poisson_deviance = {got}, hand-computed = {expected}"
1610 );
1611 assert!(got.is_finite() && got > 0.0, "deviance = {got}");
1613 }
1614
1615 struct ExponentialModel {
1618 x: Vec<f64>,
1619 flux: Vec<f64>,
1620 }
1621
1622 impl FitModel for ExponentialModel {
1623 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1624 let b = params[0]; Ok(self
1626 .x
1627 .iter()
1628 .zip(self.flux.iter())
1629 .map(|(&xi, &fi)| fi * (-b * xi).exp())
1630 .collect())
1631 }
1632 }
1633
1634 #[test]
1635 fn test_poisson_nll_perfect_match() {
1636 let y_obs = vec![10.0, 20.0, 30.0];
1637 let y_model = vec![10.0, 20.0, 30.0];
1638 let nll = poisson_nll(&y_obs, &y_model);
1639 let expected: f64 = y_obs
1641 .iter()
1642 .zip(y_model.iter())
1643 .map(|(&o, &m)| m - o * m.ln())
1644 .sum();
1645 assert!((nll - expected).abs() < 1e-10);
1646 }
1647
1648 #[test]
1649 fn test_poisson_fit_exponential() {
1650 let x: Vec<f64> = (0..20).map(|i| i as f64 * 0.5).collect();
1652 let true_b = 0.5;
1653 let flux: Vec<f64> = vec![1000.0; x.len()];
1654
1655 let model = ExponentialModel {
1656 x: x.clone(),
1657 flux: flux.clone(),
1658 };
1659
1660 let y_obs = model.evaluate(&[true_b]).unwrap();
1662
1663 let mut params = ParameterSet::new(vec![
1664 FitParameter::non_negative("b", 1.0), ]);
1666
1667 let result = poisson_fit(&model, &y_obs, &mut params, &PoissonConfig::default()).unwrap();
1668
1669 assert!(
1670 result.converged,
1671 "Poisson fit did not converge after {} iterations",
1672 result.iterations,
1673 );
1674 assert!(
1675 (result.params[0] - true_b).abs() / true_b < 0.05,
1676 "Fitted b = {}, true = {}, error = {:.1}%",
1677 result.params[0],
1678 true_b,
1679 (result.params[0] - true_b).abs() / true_b * 100.0,
1680 );
1681 }
1682
1683 #[test]
1684 fn test_poisson_fit_low_counts() {
1685 let x: Vec<f64> = (0..30).map(|i| i as f64 * 0.2).collect();
1687 let true_b = 0.3;
1688 let flux: Vec<f64> = vec![10.0; x.len()];
1689
1690 let model = ExponentialModel {
1691 x: x.clone(),
1692 flux: flux.clone(),
1693 };
1694
1695 let y_obs = model.evaluate(&[true_b]).unwrap();
1696
1697 let mut params = ParameterSet::new(vec![FitParameter::non_negative("b", 0.1)]);
1698
1699 let result = poisson_fit(&model, &y_obs, &mut params, &PoissonConfig::default()).unwrap();
1700
1701 assert!(result.converged);
1702 assert!(
1703 (result.params[0] - true_b).abs() / true_b < 0.1,
1704 "Low-count: fitted b = {}, true = {}",
1705 result.params[0],
1706 true_b,
1707 );
1708 }
1709
1710 #[test]
1711 fn test_poisson_non_negativity() {
1712 let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1714 let flux: Vec<f64> = vec![100.0; x.len()];
1715
1716 let model = ExponentialModel {
1717 x: x.clone(),
1718 flux: flux.clone(),
1719 };
1720
1721 let y_obs: Vec<f64> = vec![100.0; x.len()];
1723
1724 let mut params = ParameterSet::new(vec![FitParameter::non_negative("b", 1.0)]);
1725
1726 let result = poisson_fit(&model, &y_obs, &mut params, &PoissonConfig::default()).unwrap();
1727
1728 assert!(
1729 result.params[0] >= 0.0,
1730 "b should be non-negative, got {}",
1731 result.params[0],
1732 );
1733 assert!(
1734 result.params[0] < 0.1,
1735 "b should be ~0, got {}",
1736 result.params[0],
1737 );
1738 }
1739
1740 #[test]
1741 fn test_counts_model() {
1742 struct ConstTransmission;
1743 impl FitModel for ConstTransmission {
1744 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1745 Ok(vec![params[0]; 3])
1746 }
1747 }
1748
1749 let t_model = ConstTransmission;
1750 let flux = [100.0, 200.0, 300.0];
1751 let background = [5.0, 10.0, 15.0];
1752 let counts_model = CountsModel {
1753 transmission_model: &t_model,
1754 flux: &flux,
1755 background: &background,
1756 n_params: 1,
1757 };
1758
1759 let result = counts_model.evaluate(&[0.5]).unwrap();
1761 assert!((result[0] - 55.0).abs() < 1e-10);
1762 assert!((result[1] - 110.0).abs() < 1e-10);
1763 assert!((result[2] - 165.0).abs() < 1e-10);
1764 assert_eq!(
1765 crate::forward_model::ForwardModel::n_params(&counts_model),
1766 1
1767 );
1768 }
1769
1770 #[test]
1771 fn test_counts_background_scale_model() {
1772 struct ConstTransmission;
1773 impl FitModel for ConstTransmission {
1774 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1775 Ok(vec![params[0]; 3])
1776 }
1777
1778 fn analytical_jacobian(
1779 &self,
1780 _params: &[f64],
1781 free_param_indices: &[usize],
1782 _y_current: &[f64],
1783 ) -> Option<FlatMatrix> {
1784 let mut jac = FlatMatrix::zeros(3, free_param_indices.len());
1785 for (col, &fp) in free_param_indices.iter().enumerate() {
1786 if fp == 0 {
1787 for row in 0..3 {
1788 *jac.get_mut(row, col) = 1.0;
1789 }
1790 }
1791 }
1792 Some(jac)
1793 }
1794 }
1795
1796 let t_model = ConstTransmission;
1797 let flux = [100.0, 200.0, 300.0];
1798 let background = [5.0, 10.0, 15.0];
1799 let counts_model = CountsBackgroundScaleModel {
1800 transmission_model: &t_model,
1801 flux: &flux,
1802 background: &background,
1803 alpha1_index: 1,
1804 alpha2_index: 2,
1805 n_params: 3,
1806 };
1807
1808 let params = [0.5, 0.8, 1.5];
1809 let result = counts_model.evaluate(¶ms).unwrap();
1810 assert!((result[0] - 47.5).abs() < 1e-10);
1811 assert!((result[1] - 95.0).abs() < 1e-10);
1812 assert!((result[2] - 142.5).abs() < 1e-10);
1813 assert_eq!(
1814 crate::forward_model::ForwardModel::n_params(&counts_model),
1815 3
1816 );
1817 }
1818
1819 #[test]
1820 fn test_poisson_fit_multi_density_temperature_converges() {
1821 struct MultiDensityCountsModel {
1822 energies: Vec<f64>,
1823 flux: Vec<f64>,
1824 density_count: usize,
1825 temp_index: usize,
1826 }
1827
1828 impl MultiDensityCountsModel {
1829 fn sigma(&self, iso: usize, energy: f64, temp_k: f64) -> f64 {
1830 let center = 6.0 + iso as f64 * 4.5;
1831 let amp = 150.0 + 25.0 * iso as f64;
1832 let base_width = 0.8 + 0.12 * iso as f64;
1833 let width_coeff = 0.05 + 0.01 * iso as f64;
1834 let width = (base_width * (1.0 + width_coeff * (temp_k - 300.0) / 300.0)).max(0.1);
1835 let delta = energy - center;
1836 let gauss = (-(delta * delta) / (2.0 * width * width)).exp();
1837 amp * gauss
1838 }
1839
1840 fn dsigma_dt(&self, iso: usize, energy: f64, temp_k: f64) -> f64 {
1841 let center = 6.0 + iso as f64 * 4.5;
1842 let amp = 150.0 + 25.0 * iso as f64;
1843 let base_width = 0.8 + 0.12 * iso as f64;
1844 let width_coeff = 0.05 + 0.01 * iso as f64;
1845 let width = (base_width * (1.0 + width_coeff * (temp_k - 300.0) / 300.0)).max(0.1);
1846 let delta = energy - center;
1847 let gauss = (-(delta * delta) / (2.0 * width * width)).exp();
1848 let dwidth_dt = base_width * width_coeff / 300.0;
1849 amp * gauss * (delta * delta) * dwidth_dt / width.powi(3)
1850 }
1851 }
1852
1853 impl FitModel for MultiDensityCountsModel {
1854 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1855 let temp_k = params[self.temp_index];
1856 let mut out = Vec::with_capacity(self.energies.len());
1857 for (i, &energy) in self.energies.iter().enumerate() {
1858 let optical_depth = (0..self.density_count)
1859 .map(|iso| params[iso] * self.sigma(iso, energy, temp_k))
1860 .sum::<f64>();
1861 out.push(self.flux[i] * (-optical_depth).exp());
1862 }
1863 Ok(out)
1864 }
1865
1866 fn analytical_jacobian(
1867 &self,
1868 params: &[f64],
1869 free_param_indices: &[usize],
1870 y_current: &[f64],
1871 ) -> Option<crate::lm::FlatMatrix> {
1872 let temp_k = params[self.temp_index];
1873 let mut jac =
1874 crate::lm::FlatMatrix::zeros(self.energies.len(), free_param_indices.len());
1875 for (row, &energy) in self.energies.iter().enumerate() {
1876 let y = y_current[row];
1877 let mut sum_n_dsigma_dt = 0.0;
1878 for (iso, &density) in params[..self.density_count].iter().enumerate() {
1879 sum_n_dsigma_dt += density * self.dsigma_dt(iso, energy, temp_k);
1880 }
1881 for (col, &fp) in free_param_indices.iter().enumerate() {
1882 let val = if fp == self.temp_index {
1883 -y * sum_n_dsigma_dt
1884 } else {
1885 -y * self.sigma(fp, energy, temp_k)
1886 };
1887 *jac.get_mut(row, col) = val;
1888 }
1889 }
1890 Some(jac)
1891 }
1892 }
1893
1894 let energies: Vec<f64> = (0..220).map(|i| 1.0 + 0.18 * i as f64).collect();
1895 let flux: Vec<f64> = energies
1896 .iter()
1897 .map(|&e| 1500.0 * (1.0 + 0.15 * (e / 8.0).sin()).max(0.2))
1898 .collect();
1899 let density_count = 6usize;
1900 let temp_index = density_count;
1901 let model = MultiDensityCountsModel {
1902 energies,
1903 flux,
1904 density_count,
1905 temp_index,
1906 };
1907
1908 let true_params = vec![3.2e-4, 2.4e-4, 1.7e-4, 1.1e-4, 7.5e-5, 4.2e-5, 360.0];
1909 let y_obs = model.evaluate(&true_params).unwrap();
1910
1911 let mut params = ParameterSet::new(vec![
1912 FitParameter::non_negative("n0", 6.0e-4),
1913 FitParameter::non_negative("n1", 4.0e-4),
1914 FitParameter::non_negative("n2", 2.5e-4),
1915 FitParameter::non_negative("n3", 1.8e-4),
1916 FitParameter::non_negative("n4", 1.0e-4),
1917 FitParameter::non_negative("n5", 8.0e-5),
1918 FitParameter {
1919 name: "temperature_k".into(),
1920 value: 300.0,
1921 lower: 1.0,
1922 upper: 5000.0,
1923 fixed: false,
1924 },
1925 ]);
1926
1927 let config = PoissonConfig {
1928 max_iter: 200,
1929 gauss_newton_lambda: 1e-4,
1930 ..PoissonConfig::default()
1931 };
1932 let result = poisson_fit(&model, &y_obs, &mut params, &config).unwrap();
1933
1934 assert!(
1935 result.converged,
1936 "multi-density+temperature Poisson fit did not converge after {} iterations",
1937 result.iterations,
1938 );
1939 assert!(
1940 result.iterations < config.max_iter,
1941 "fit hit max_iter={}, params={:?}",
1942 config.max_iter,
1943 result.params,
1944 );
1945
1946 for (i, (&fit, &truth)) in result.params[..density_count]
1947 .iter()
1948 .zip(true_params[..density_count].iter())
1949 .enumerate()
1950 {
1951 let rel_err = (fit - truth).abs() / truth;
1952 assert!(
1953 rel_err < 0.10,
1954 "density[{i}] fit={fit} truth={truth} rel_err={rel_err:.3}",
1955 );
1956 }
1957
1958 let fitted_temp = result.params[temp_index];
1959 assert!(
1960 (fitted_temp - true_params[temp_index]).abs() < 10.0,
1961 "temperature fit={fitted_temp} truth={}",
1962 true_params[temp_index],
1963 );
1964 assert!(
1965 result.iterations <= 80,
1966 "expected analytical KL path to converge well before max_iter; got {}",
1967 result.iterations,
1968 );
1969 }
1970
1971 #[test]
1972 fn test_poisson_fit_exact_optimum_without_analytical_jacobian_converges() {
1973 let x: Vec<f64> = (0..20).map(|i| i as f64 * 0.5).collect();
1974 let true_b = 0.5;
1975 let flux: Vec<f64> = vec![1000.0; x.len()];
1976
1977 let model = ExponentialModel { x, flux };
1978 let y_obs = model.evaluate(&[true_b]).unwrap();
1979 let mut params = ParameterSet::new(vec![FitParameter::non_negative("b", true_b)]);
1980 let config = PoissonConfig {
1981 fd_step: 1e-4,
1982 tol_param: 1e-12,
1983 max_iter: 50,
1984 ..PoissonConfig::default()
1985 };
1986
1987 let result = poisson_fit(&model, &y_obs, &mut params, &config).unwrap();
1988
1989 assert!(
1990 result.converged,
1991 "exact-optimum FD fit should converge instead of exhausting line search"
1992 );
1993 assert!(
1994 result.iterations < config.max_iter,
1995 "fit should stop by convergence, not hit max_iter"
1996 );
1997 assert!(
1998 (result.params[0] - true_b).abs() < 1e-6,
1999 "parameter drifted away from optimum: {}",
2000 result.params[0]
2001 );
2002 }
2003
2004 #[test]
2005 fn test_projected_gradient_ignores_lower_bound_blocked_direction() {
2006 let params = ParameterSet::new(vec![
2007 FitParameter::non_negative("density", 0.0),
2008 FitParameter::unbounded("temp", 300.0),
2009 ]);
2010 let free_idx = vec![0, 1];
2011 let grad = vec![0.25, -0.5];
2012
2013 let inactive = inactive_free_positions(¶ms, &free_idx, &grad);
2014 assert_eq!(
2015 inactive,
2016 vec![1],
2017 "lower-bound blocked density should be active"
2018 );
2019
2020 let pg_norm = projected_gradient_norm(¶ms, &free_idx, &grad);
2021 assert!(
2022 (pg_norm - 0.5).abs() < 1e-12,
2023 "projected gradient should ignore blocked lower-bound component"
2024 );
2025 }
2026
2027 #[test]
2028 fn test_max_feasible_step_hits_lower_bound() {
2029 let params = ParameterSet::new(vec![
2030 FitParameter::non_negative("density", 0.2),
2031 FitParameter::unbounded("temp", 300.0),
2032 ]);
2033 let alpha = max_feasible_step(¶ms, &[0, 1], &[0.2, 300.0], &[0.5, 0.0]);
2034 assert!(
2035 (alpha - 0.4).abs() < 1e-12,
2036 "feasible step should stop exactly at lower bound"
2037 );
2038 }
2039
2040 #[test]
2041 fn test_max_feasible_step_hits_upper_bound() {
2042 let params = ParameterSet::new(vec![FitParameter {
2043 name: "temp".into(),
2044 value: 300.0,
2045 lower: 1.0,
2046 upper: 500.0,
2047 fixed: false,
2048 }]);
2049 let alpha = max_feasible_step(¶ms, &[0], &[300.0], &[-50.0]);
2050 assert!(
2051 (alpha - 4.0).abs() < 1e-12,
2052 "feasible step should stop exactly at upper bound"
2053 );
2054 }
2055
2056 #[test]
2057 fn test_inactive_mask_changes_when_bound_activity_changes() {
2058 let free_idx = vec![0, 1];
2059 let params_at_bound = ParameterSet::new(vec![
2060 FitParameter::non_negative("density", 0.0),
2061 FitParameter::non_negative("temp", 1.0),
2062 ]);
2063 let params_free = ParameterSet::new(vec![
2064 FitParameter::non_negative("density", 0.2),
2065 FitParameter::non_negative("temp", 1.0),
2066 ]);
2067
2068 let mask_at_bound = inactive_free_mask(¶ms_at_bound, &free_idx, &[0.3, -0.2]);
2069 let mask_free = inactive_free_mask(¶ms_free, &free_idx, &[0.3, -0.2]);
2070
2071 assert_eq!(mask_at_bound, vec![false, true]);
2072 assert_eq!(mask_free, vec![true, true]);
2073 assert_ne!(
2074 mask_at_bound, mask_free,
2075 "active-set changes should invalidate FD quasi-Newton history"
2076 );
2077 }
2078
2079 #[test]
2080 fn test_lbfgs_history_two_loop_matches_secant_direction() {
2081 let mut history = LbfgsHistory::new(4);
2082 history.update(&[0.0], &[1.0], &[0.0], &[2.0]);
2083 let dir = history
2084 .apply_on_positions(&[4.0], &[0])
2085 .expect("history should produce a direction");
2086 assert!(
2087 (dir[0] - 2.0).abs() < 1e-12,
2088 "1D secant pair should scale gradient by inverse curvature"
2089 );
2090 }
2091
2092 #[test]
2093 fn test_lbfgs_subspace_ignores_active_components() {
2094 let mut history = LbfgsHistory::new(4);
2095 history.update(&[0.0, 0.0], &[1.0, 100.0], &[0.0, 0.0], &[2.0, 100.0]);
2096 let dir = history
2097 .apply_on_positions(&[4.0, 50.0], &[0])
2098 .expect("subspace history should produce a direction");
2099 assert!(
2100 (dir[0] - 2.0).abs() < 1e-12,
2101 "inactive-subspace L-BFGS should match 1D secant scaling on the free variable"
2102 );
2103 assert!(
2104 dir[1].abs() < 1e-12,
2105 "inactive-subspace L-BFGS should not leak blocked-variable history into the direction"
2106 );
2107 }
2108
2109 #[test]
2110 fn test_poisson_fit_converges_at_bound_active_optimum() {
2111 struct OffsetModel {
2112 base: Vec<f64>,
2113 }
2114
2115 impl FitModel for OffsetModel {
2116 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
2117 Ok(self.base.iter().map(|&b| b + params[0]).collect())
2118 }
2119
2120 fn analytical_jacobian(
2121 &self,
2122 _params: &[f64],
2123 free_param_indices: &[usize],
2124 y_current: &[f64],
2125 ) -> Option<FlatMatrix> {
2126 let mut jac = FlatMatrix::zeros(y_current.len(), free_param_indices.len());
2127 for (col, &fp) in free_param_indices.iter().enumerate() {
2128 assert_eq!(fp, 0);
2129 for row in 0..y_current.len() {
2130 *jac.get_mut(row, col) = 1.0;
2131 }
2132 }
2133 Some(jac)
2134 }
2135 }
2136
2137 let model = OffsetModel {
2138 base: vec![10.0; 12],
2139 };
2140 let y_obs = vec![8.0; 12];
2141 let mut params = ParameterSet::new(vec![FitParameter::non_negative("offset", 0.0)]);
2142
2143 let result = poisson_fit(&model, &y_obs, &mut params, &PoissonConfig::default()).unwrap();
2144
2145 assert!(
2146 result.converged,
2147 "bound-active optimum should satisfy projected optimality"
2148 );
2149 assert_eq!(
2150 result.iterations, 1,
2151 "should stop on projected-gradient check"
2152 );
2153 assert!(
2154 result.params[0].abs() < 1e-12,
2155 "offset should stay pinned at lower bound, got {}",
2156 result.params[0]
2157 );
2158 }
2159
2160 #[test]
2161 fn test_poisson_fit_fd_lbfgs_handles_coupled_two_parameter_model() {
2162 struct CoupledExponentialModel {
2163 x: Vec<f64>,
2164 }
2165
2166 impl FitModel for CoupledExponentialModel {
2167 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
2168 let amp = params[0];
2169 let decay = params[1];
2170 Ok(self
2171 .x
2172 .iter()
2173 .map(|&x| amp * (-decay * x).exp() + 1.0)
2174 .collect())
2175 }
2176 }
2177
2178 let model = CoupledExponentialModel {
2179 x: (0..60).map(|i| i as f64 * 0.08).collect(),
2180 };
2181 let true_params = [120.0, 0.45];
2182 let y_obs = model.evaluate(&true_params).unwrap();
2183 let mut params = ParameterSet::new(vec![
2184 FitParameter::non_negative("amp", 30.0),
2185 FitParameter::non_negative("decay", 1.2),
2186 ]);
2187 let config = PoissonConfig {
2188 max_iter: 120,
2189 lbfgs_history: 8,
2190 ..PoissonConfig::default()
2191 };
2192 let result = poisson_fit(&model, &y_obs, &mut params, &config).unwrap();
2193 let mut baseline_params = ParameterSet::new(vec![
2194 FitParameter::non_negative("amp", 30.0),
2195 FitParameter::non_negative("decay", 1.2),
2196 ]);
2197 let baseline = poisson_fit(
2198 &model,
2199 &y_obs,
2200 &mut baseline_params,
2201 &PoissonConfig {
2202 lbfgs_history: 0,
2203 ..config.clone()
2204 },
2205 )
2206 .unwrap();
2207
2208 assert!(
2209 result.converged,
2210 "FD L-BFGS fit did not converge: {result:?}"
2211 );
2212 assert!(
2213 baseline.converged,
2214 "baseline FD fit should still converge: {baseline:?}"
2215 );
2216 assert!(
2217 result.iterations <= 60,
2218 "expected FD quasi-Newton path to converge well before max_iter; got {}",
2219 result.iterations,
2220 );
2221 assert!(
2222 result.iterations < baseline.iterations,
2223 "L-BFGS fallback should beat no-history gradient scaling: lbfgs={} baseline={}",
2224 result.iterations,
2225 baseline.iterations,
2226 );
2227 assert!(
2228 (result.params[0] - true_params[0]).abs() / true_params[0] < 0.02,
2229 "amplitude fit={}, true={}",
2230 result.params[0],
2231 true_params[0],
2232 );
2233 assert!(
2234 (result.params[1] - true_params[1]).abs() / true_params[1] < 0.02,
2235 "decay fit={}, true={}",
2236 result.params[1],
2237 true_params[1],
2238 );
2239 }
2240
2241 #[test]
2242 fn test_poisson_fit_fd_lbfgs_with_bound_active_offset_uses_subspace() {
2243 struct OffsetDecayModel {
2244 x: Vec<f64>,
2245 }
2246
2247 impl FitModel for OffsetDecayModel {
2248 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
2249 let offset = params[0];
2250 let decay = params[1];
2251 Ok(self
2252 .x
2253 .iter()
2254 .map(|&x| offset + (-decay * x).exp())
2255 .collect())
2256 }
2257 }
2258
2259 let model = OffsetDecayModel {
2260 x: (0..60).map(|i| i as f64 * 0.08).collect(),
2261 };
2262 let true_params = [0.0, 0.35];
2263 let y_obs = model.evaluate(&true_params).unwrap();
2264
2265 let config = PoissonConfig {
2266 max_iter: 120,
2267 lbfgs_history: 8,
2268 ..PoissonConfig::default()
2269 };
2270 let mut params = ParameterSet::new(vec![
2271 FitParameter::non_negative("offset", 0.0),
2272 FitParameter::non_negative("decay", 1.1),
2273 ]);
2274 let result = poisson_fit(&model, &y_obs, &mut params, &config).unwrap();
2275 assert!(
2276 result.converged,
2277 "subspace FD L-BFGS fit did not converge: {result:?}"
2278 );
2279 assert!(
2280 result.iterations <= 20,
2281 "bound-active subspace FD fit should converge comfortably before max_iter; got {}",
2282 result.iterations,
2283 );
2284 assert!(
2285 result.params[0].abs() < 1e-8,
2286 "offset should remain at the lower bound, got {}",
2287 result.params[0]
2288 );
2289 assert!(
2290 (result.params[1] - true_params[1]).abs() / true_params[1] < 0.02,
2291 "decay fit={}, true={}",
2292 result.params[1],
2293 true_params[1],
2294 );
2295 }
2296
2297 #[test]
2298 fn test_poisson_fit_temperature_and_background_converges() {
2299 struct TempTransmissionModel {
2300 energies: Vec<f64>,
2301 }
2302
2303 impl TempTransmissionModel {
2304 fn sigma(&self, energy: f64, temp_k: f64) -> f64 {
2305 let center = 6.0;
2306 let amp = 110.0;
2307 let base_width = 0.55;
2308 let width = (base_width * (temp_k / 300.0).sqrt()).max(0.08);
2309 let delta = energy - center;
2310 amp * (-(delta * delta) / (2.0 * width * width)).exp()
2311 }
2312
2313 fn dsigma_dt(&self, energy: f64, temp_k: f64) -> f64 {
2314 let center = 6.0;
2315 let amp = 110.0;
2316 let base_width = 0.55;
2317 let width = (base_width * (temp_k / 300.0).sqrt()).max(0.08);
2318 let dwidth_dt = if temp_k > 0.0 {
2319 base_width / (2.0 * (300.0 * temp_k).sqrt())
2320 } else {
2321 0.0
2322 };
2323 let delta = energy - center;
2324 let gauss = (-(delta * delta) / (2.0 * width * width)).exp();
2325 amp * gauss * (delta * delta) * dwidth_dt / width.powi(3)
2326 }
2327 }
2328
2329 impl FitModel for TempTransmissionModel {
2330 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
2331 let density = params[0];
2332 let temp_k = params[1];
2333 Ok(self
2334 .energies
2335 .iter()
2336 .map(|&energy| (-density * self.sigma(energy, temp_k)).exp())
2337 .collect())
2338 }
2339
2340 fn analytical_jacobian(
2341 &self,
2342 params: &[f64],
2343 free_param_indices: &[usize],
2344 y_current: &[f64],
2345 ) -> Option<FlatMatrix> {
2346 let density = params[0];
2347 let temp_k = params[1];
2348 let mut jac = FlatMatrix::zeros(self.energies.len(), free_param_indices.len());
2349 for (row, &energy) in self.energies.iter().enumerate() {
2350 let y = y_current[row];
2351 let sigma = self.sigma(energy, temp_k);
2352 let dsigma_dt = self.dsigma_dt(energy, temp_k);
2353 for (col, &fp) in free_param_indices.iter().enumerate() {
2354 let deriv = match fp {
2355 0 => -sigma * y,
2356 1 => -density * dsigma_dt * y,
2357 _ => unreachable!("unexpected parameter index {fp}"),
2358 };
2359 *jac.get_mut(row, col) = deriv;
2360 }
2361 }
2362 Some(jac)
2363 }
2364 }
2365
2366 let energies: Vec<f64> = (0..180).map(|i| 1.0 + 0.06 * i as f64).collect();
2367 let inner = TempTransmissionModel {
2368 energies: energies.clone(),
2369 };
2370 let inv_sqrt_energies: Vec<f64> = energies.iter().map(|&e| 1.0 / e.sqrt()).collect();
2371 let wrapped = TransmissionKLBackgroundModel {
2372 inner: &inner,
2373 inv_sqrt_energies,
2374 b0_index: 2,
2375 b1_index: 3,
2376 n_params: 4,
2377 };
2378
2379 let true_params = vec![4.5e-4, 345.0, 0.012, 0.008];
2380 let y_obs = wrapped.evaluate(&true_params).unwrap();
2381
2382 let mut params = ParameterSet::new(vec![
2383 FitParameter::non_negative("density", 8.0e-4),
2384 FitParameter {
2385 name: "temperature_k".into(),
2386 value: 290.0,
2387 lower: 1.0,
2388 upper: 5000.0,
2389 fixed: false,
2390 },
2391 FitParameter {
2392 name: "kl_b0".into(),
2393 value: 0.0,
2394 lower: 0.0,
2395 upper: 0.5,
2396 fixed: false,
2397 },
2398 FitParameter {
2399 name: "kl_b1".into(),
2400 value: 0.0,
2401 lower: 0.0,
2402 upper: 0.5,
2403 fixed: false,
2404 },
2405 ]);
2406
2407 let config = PoissonConfig {
2408 max_iter: 120,
2409 gauss_newton_lambda: 1e-4,
2410 ..PoissonConfig::default()
2411 };
2412 let result = poisson_fit(&wrapped, &y_obs, &mut params, &config).unwrap();
2413
2414 assert!(result.converged, "fit did not converge: {result:?}");
2415 assert!(
2416 result.iterations <= 80,
2417 "expected convergence well before max_iter; got {}",
2418 result.iterations,
2419 );
2420 assert!(
2421 (result.params[0] - true_params[0]).abs() / true_params[0] < 0.05,
2422 "density fit={}, true={}",
2423 result.params[0],
2424 true_params[0],
2425 );
2426 assert!(
2427 (result.params[1] - true_params[1]).abs() < 8.0,
2428 "temperature fit={}, true={}",
2429 result.params[1],
2430 true_params[1],
2431 );
2432 assert!(
2433 (result.params[2] - true_params[2]).abs() < 5e-3,
2434 "b0 fit={}, true={}",
2435 result.params[2],
2436 true_params[2],
2437 );
2438 assert!(
2439 (result.params[3] - true_params[3]).abs() < 5e-3,
2440 "b1 fit={}, true={}",
2441 result.params[3],
2442 true_params[3],
2443 );
2444 }
2445
2446 #[test]
2450 fn test_transmission_kl_background_fd_fallback_when_inner_lacks_jacobian() {
2451 let x: Vec<f64> = (0..40).map(|i| 1.0 + 0.25 * i as f64).collect();
2455 let inner = ExponentialModel {
2456 x: x.clone(),
2457 flux: vec![1000.0; x.len()],
2458 };
2459 let inv_sqrt_energies: Vec<f64> = x.iter().map(|&e| 1.0 / e.sqrt()).collect();
2460 let wrapped = TransmissionKLBackgroundModel {
2461 inner: &inner,
2462 inv_sqrt_energies,
2463 b0_index: 1,
2464 b1_index: 2,
2465 n_params: 3,
2466 };
2467
2468 let true_params = vec![0.4, 20.0, 10.0];
2469 let y_obs = wrapped.evaluate(&true_params).unwrap();
2470
2471 assert!(
2474 wrapped
2475 .analytical_jacobian(&true_params, &[0, 1, 2], &y_obs)
2476 .is_none(),
2477 "inner param free without inner analytical_jacobian must give None"
2478 );
2479 assert!(
2480 wrapped
2481 .analytical_jacobian(&true_params, &[0], &y_obs)
2482 .is_none(),
2483 "inner-only free set without inner analytical_jacobian must give None"
2484 );
2485
2486 let mut params = ParameterSet::new(vec![
2487 FitParameter::non_negative("density", 0.8),
2488 FitParameter {
2489 name: "kl_b0".into(),
2490 value: 5.0,
2491 lower: 0.0,
2492 upper: 500.0,
2493 fixed: false,
2494 },
2495 FitParameter {
2496 name: "kl_b1".into(),
2497 value: 5.0,
2498 lower: 0.0,
2499 upper: 500.0,
2500 fixed: false,
2501 },
2502 ]);
2503 let config = PoissonConfig {
2504 max_iter: 300,
2505 ..PoissonConfig::default()
2506 };
2507 let result = poisson_fit(&wrapped, &y_obs, &mut params, &config).unwrap();
2508 assert!(
2509 result.converged,
2510 "FD-fallback fit did not converge: {result:?}"
2511 );
2512 assert!(
2513 (result.params[0] - true_params[0]).abs() / true_params[0] < 0.05,
2514 "density fit={}, true={}",
2515 result.params[0],
2516 true_params[0],
2517 );
2518 }
2519
2520 #[test]
2525 fn test_transmission_kl_background_background_only_analytic_jacobian() {
2526 let x: Vec<f64> = (0..40).map(|i| 1.0 + 0.25 * i as f64).collect();
2528 let inner = ExponentialModel {
2529 x: x.clone(),
2530 flux: vec![1000.0; x.len()],
2531 };
2532 let inv_sqrt_energies: Vec<f64> = x.iter().map(|&e| 1.0 / e.sqrt()).collect();
2533 let wrapped = TransmissionKLBackgroundModel {
2534 inner: &inner,
2535 inv_sqrt_energies: inv_sqrt_energies.clone(),
2536 b0_index: 1,
2537 b1_index: 2,
2538 n_params: 3,
2539 };
2540
2541 let true_params = vec![0.4, 20.0, 10.0];
2542 let y_obs = wrapped.evaluate(&true_params).unwrap();
2543
2544 let jac = wrapped
2545 .analytical_jacobian(&true_params, &[1, 2], &y_obs)
2546 .expect("background-only free set must stay on the analytic path");
2547 for (row, &inv_sqrt_e) in inv_sqrt_energies.iter().enumerate() {
2548 assert!(
2549 (jac.get(row, 0) - 1.0).abs() < 1e-15,
2550 "∂T/∂b₀ at row {row} = {}, expected 1.0",
2551 jac.get(row, 0),
2552 );
2553 assert!(
2554 (jac.get(row, 1) - inv_sqrt_e).abs() < 1e-15,
2555 "∂T/∂b₁ at row {row} = {}, expected {inv_sqrt_e}",
2556 jac.get(row, 1),
2557 );
2558 }
2559
2560 let mut params = ParameterSet::new(vec![
2563 FitParameter::fixed("density", true_params[0]),
2564 FitParameter {
2565 name: "kl_b0".into(),
2566 value: 5.0,
2567 lower: 0.0,
2568 upper: 500.0,
2569 fixed: false,
2570 },
2571 FitParameter {
2572 name: "kl_b1".into(),
2573 value: 5.0,
2574 lower: 0.0,
2575 upper: 500.0,
2576 fixed: false,
2577 },
2578 ]);
2579 let config = PoissonConfig {
2580 max_iter: 300,
2581 ..PoissonConfig::default()
2582 };
2583 let result = poisson_fit(&wrapped, &y_obs, &mut params, &config).unwrap();
2584 assert!(
2585 result.converged,
2586 "background-only analytic fit did not converge: {result:?}"
2587 );
2588 assert!(
2589 (result.params[1] - true_params[1]).abs() < 0.1,
2590 "b0 fit={}, true={}",
2591 result.params[1],
2592 true_params[1],
2593 );
2594 assert!(
2595 (result.params[2] - true_params[2]).abs() < 0.1,
2596 "b1 fit={}, true={}",
2597 result.params[2],
2598 true_params[2],
2599 );
2600 }
2601
2602 fn fd_column(model: &dyn FitModel, params: &[f64], param_index: usize, h: f64) -> Vec<f64> {
2606 let mut plus = params.to_vec();
2607 plus[param_index] += h;
2608 let mut minus = params.to_vec();
2609 minus[param_index] -= h;
2610 let y_plus = model.evaluate(&plus).unwrap();
2611 let y_minus = model.evaluate(&minus).unwrap();
2612 y_plus
2613 .iter()
2614 .zip(y_minus.iter())
2615 .map(|(&p, &m)| (p - m) / (2.0 * h))
2616 .collect()
2617 }
2618
2619 #[test]
2623 fn test_transmission_kl_background_aliased_indices_jacobian_matches_fd() {
2624 let x: Vec<f64> = (0..10).map(|i| 1.0 + 0.5 * i as f64).collect();
2625 let inner = ExponentialModel {
2626 x: x.clone(),
2627 flux: vec![1000.0; x.len()],
2628 };
2629 let inv_sqrt_energies: Vec<f64> = x.iter().map(|&e| 1.0 / e.sqrt()).collect();
2630 let wrapped = TransmissionKLBackgroundModel {
2631 inner: &inner,
2632 inv_sqrt_energies: inv_sqrt_energies.clone(),
2633 b0_index: 1,
2634 b1_index: 1, n_params: 2,
2636 };
2637
2638 let params = vec![0.4, 15.0];
2639 let y = wrapped.evaluate(¶ms).unwrap();
2640 let jac = wrapped
2643 .analytical_jacobian(¶ms, &[1], &y)
2644 .expect("background-only free set must stay on the analytic path");
2645
2646 let fd = fd_column(&wrapped, ¶ms, 1, 1e-6);
2647 for (row, (&fd_val, &inv_sqrt_e)) in fd.iter().zip(inv_sqrt_energies.iter()).enumerate() {
2648 let expected = 1.0 + inv_sqrt_e;
2649 assert!(
2650 (jac.get(row, 0) - expected).abs() < 1e-12,
2651 "aliased ∂/∂b at row {row}: analytic {}, expected {expected}",
2652 jac.get(row, 0),
2653 );
2654 assert!(
2655 (jac.get(row, 0) - fd_val).abs() < 1e-5,
2656 "aliased ∂/∂b at row {row}: analytic {}, FD {fd_val}",
2657 jac.get(row, 0),
2658 );
2659 }
2660 }
2661
2662 #[test]
2666 fn test_counts_background_scale_aliased_indices_jacobian_matches_fd() {
2667 let x: Vec<f64> = (0..10).map(|i| 1.0 + 0.5 * i as f64).collect();
2668 let inner = ExponentialModel {
2669 x: x.clone(),
2670 flux: vec![1.0; x.len()], };
2672 let flux: Vec<f64> = vec![1000.0; x.len()];
2673 let background: Vec<f64> = x.iter().map(|&e| 5.0 + e).collect();
2674 let wrapped = CountsBackgroundScaleModel {
2675 transmission_model: &inner,
2676 flux: &flux,
2677 background: &background,
2678 alpha1_index: 1,
2679 alpha2_index: 1, n_params: 2,
2681 };
2682
2683 let params = vec![0.4, 1.2];
2684 let y = wrapped.evaluate(¶ms).unwrap();
2685 let t_inner = inner.evaluate(¶ms).unwrap();
2686 let jac = wrapped
2689 .analytical_jacobian(¶ms, &[1], &y)
2690 .expect("scale-only free set must stay on the analytic path");
2691
2692 let fd = fd_column(&wrapped, ¶ms, 1, 1e-6);
2693 for (row, &fd_val) in fd.iter().enumerate() {
2694 let expected = flux[row] * t_inner[row] + background[row];
2695 assert!(
2696 (jac.get(row, 0) - expected).abs() < 1e-9,
2697 "aliased ∂/∂α at row {row}: analytic {}, expected {expected}",
2698 jac.get(row, 0),
2699 );
2700 assert!(
2701 (jac.get(row, 0) - fd_val).abs() < 1e-3,
2702 "aliased ∂/∂α at row {row}: analytic {}, FD {fd_val}",
2703 jac.get(row, 0),
2704 );
2705 }
2706 }
2707}