1use std::collections::HashSet;
14
15use nereids_physics::counts_response::{
16 TwoArmCountPrediction, TwoArmCounts, add_count_backgrounds,
17};
18
19use crate::error::FittingError;
20use crate::lm::{FlatMatrix, invert_matrix};
21use crate::poisson::PoissonConfig;
22
23#[derive(Debug, Clone, PartialEq)]
29pub struct TwoArmBackgroundTemplate {
30 pub name: String,
32 pub open_beam: Vec<f64>,
34 pub sample: Vec<f64>,
36}
37
38#[derive(Debug, Clone)]
40pub struct TwoArmBackgroundFitResult {
41 pub names: Vec<String>,
43 pub amplitudes: Vec<f64>,
45 pub amplitude_uncertainties: Option<Vec<f64>>,
65 pub amplitude_at_bound: Vec<bool>,
69 pub amplitudes_identifiable: bool,
76 pub prediction: TwoArmCountPrediction,
78 pub poisson_deviance: f64,
80 pub deviance_per_dof: f64,
82 pub n_informative: usize,
89 pub converged: bool,
91 pub iterations: usize,
94}
95
96#[allow(clippy::too_many_arguments)]
107pub fn fit_two_arm_background_templates(
108 observed_open_counts: &[f64],
109 observed_sample_counts: &[f64],
110 neutron_signal: TwoArmCounts,
111 open_exposure_scale: f64,
112 sample_exposure_scale: f64,
113 templates: &[TwoArmBackgroundTemplate],
114 initial_amplitudes: &[f64],
115 config: &PoissonConfig,
116) -> Result<TwoArmBackgroundFitResult, FittingError> {
117 validate_fit_inputs(
118 observed_open_counts,
119 observed_sample_counts,
120 &neutron_signal,
121 open_exposure_scale,
122 sample_exposure_scale,
123 templates,
124 initial_amplitudes,
125 )?;
126 validate_config(config)?;
127 let neutron_signal =
128 scale_neutron_signal(neutron_signal, open_exposure_scale, sample_exposure_scale)?;
129 let n_bins = observed_open_counts.len();
130 let mut observed_joined = Vec::with_capacity(2 * n_bins);
131 observed_joined.extend_from_slice(observed_open_counts);
132 observed_joined.extend_from_slice(observed_sample_counts);
133
134 let (normalized_templates, template_scales) = normalize_templates(templates);
139 let normalized_initial_amplitudes: Vec<f64> = initial_amplitudes
140 .iter()
141 .zip(&template_scales)
142 .map(|(&litude, &scale)| amplitude * scale)
143 .collect();
144 if normalized_initial_amplitudes
145 .iter()
146 .any(|value| !value.is_finite())
147 {
148 return Err(FittingError::InvalidConfig(
149 "initial amplitude times template scale must be finite".into(),
150 ));
151 }
152
153 let mut neutron_joined = Vec::with_capacity(2 * n_bins);
154 neutron_joined.extend_from_slice(&neutron_signal.open_beam);
155 neutron_joined.extend_from_slice(&neutron_signal.sample);
156 let normalized_basis: Vec<Vec<f64>> = normalized_templates
157 .iter()
158 .map(|template| {
159 let mut joined = Vec::with_capacity(2 * n_bins);
160 joined.extend_from_slice(&template.open_beam);
161 joined.extend_from_slice(&template.sample);
162 joined
163 })
164 .collect();
165
166 let n_informative =
167 count_informative_bins(&observed_joined, &neutron_joined, &normalized_basis);
168 let template_rank = background_template_rank(&normalized_basis);
169 let amplitudes_identifiable = template_rank == normalized_basis.len();
170 if n_informative <= template_rank {
175 return Err(FittingError::InvalidConfig(format!(
176 "{} background amplitudes spanning {template_rank} independent direction(s) cannot \
177 be fitted from {n_informative} informative count value(s) with positive degrees of \
178 freedom",
179 templates.len(),
180 )));
181 }
182
183 let fit = fit_non_negative_poisson_linear(
184 &observed_joined,
185 &neutron_joined,
186 &normalized_basis,
187 &normalized_initial_amplitudes,
188 config,
189 )?;
190
191 let amplitudes: Vec<f64> = fit
192 .amplitudes
193 .iter()
194 .zip(&template_scales)
195 .map(|(&normalized_amplitude, &scale)| normalized_amplitude / scale)
196 .collect();
197 let unrepresentable =
201 amplitudes
202 .iter()
203 .zip(&fit.amplitudes)
204 .any(|(&litude, &normalized)| {
205 !amplitude.is_finite() || (amplitude == 0.0 && normalized != 0.0)
206 });
207 if unrepresentable {
208 return Err(FittingError::EvaluationFailed(
209 "a fitted amplitude cannot be represented in the supplied template units; rescale the template counts"
210 .into(),
211 ));
212 }
213 let amplitude_uncertainties = amplitudes_identifiable
214 .then(|| {
215 fit.uncertainties.map(|uncertainties| {
216 uncertainties
217 .iter()
218 .zip(&template_scales)
219 .map(|(&uncertainty, &scale)| {
220 let caller_units = uncertainty / scale;
221 if caller_units.is_finite() && caller_units > 0.0 {
222 caller_units
223 } else {
224 f64::NAN
225 }
226 })
227 .collect()
228 })
229 })
230 .flatten();
231
232 let mut open_background = vec![0.0; n_bins];
233 let mut sample_background = vec![0.0; n_bins];
234 for (&litude, template) in fit.amplitudes.iter().zip(&normalized_templates) {
235 add_scaled(&mut open_background, &template.open_beam, amplitude);
236 add_scaled(&mut sample_background, &template.sample, amplitude);
237 }
238 let prediction = add_count_backgrounds(neutron_signal, &open_background, &sample_background)
239 .map_err(|error| FittingError::EvaluationFailed(error.to_string()))?;
240 let mut predicted_joined = Vec::with_capacity(2 * n_bins);
241 predicted_joined.extend_from_slice(&prediction.open_beam.total);
242 predicted_joined.extend_from_slice(&prediction.sample.total);
243 let poisson_deviance = poisson_deviance(&observed_joined, &predicted_joined);
244 let dof = n_informative - template_rank;
249
250 Ok(TwoArmBackgroundFitResult {
251 names: templates
252 .iter()
253 .map(|template| template.name.clone())
254 .collect(),
255 amplitudes,
256 amplitude_uncertainties,
257 amplitude_at_bound: fit.at_bound,
258 amplitudes_identifiable,
259 prediction,
260 poisson_deviance,
261 deviance_per_dof: poisson_deviance / dof as f64,
262 n_informative,
263 converged: fit.converged,
264 iterations: fit.iterations,
265 })
266}
267
268fn count_informative_bins(observed: &[f64], neutron_signal: &[f64], basis: &[Vec<f64>]) -> usize {
281 (0..observed.len())
282 .filter(|&bin| {
283 observed[bin] > 0.0
284 || neutron_signal[bin] > 0.0
285 || basis.iter().any(|template| template[bin] > 0.0)
286 })
287 .count()
288}
289
290fn scale_neutron_signal(
291 mut signal: TwoArmCounts,
292 open_exposure_scale: f64,
293 sample_exposure_scale: f64,
294) -> Result<TwoArmCounts, FittingError> {
295 for value in &mut signal.open_beam {
296 *value *= open_exposure_scale;
297 }
298 for value in &mut signal.sample {
299 *value *= sample_exposure_scale;
300 }
301 signal.open_beam_window_loss *= open_exposure_scale;
305 signal.sample_window_loss *= sample_exposure_scale;
306 if signal
307 .open_beam
308 .iter()
309 .chain(&signal.sample)
310 .chain(std::iter::once(&signal.open_beam_window_loss))
311 .chain(std::iter::once(&signal.sample_window_loss))
312 .any(|value| !value.is_finite())
313 {
314 return Err(FittingError::InvalidConfig(
315 "exposure-scaled neutron signal must remain finite; rescale the reference signal"
316 .into(),
317 ));
318 }
319 Ok(signal)
320}
321
322struct LinearPoissonFit {
323 amplitudes: Vec<f64>,
324 uncertainties: Option<Vec<f64>>,
325 at_bound: Vec<bool>,
326 converged: bool,
327 iterations: usize,
328}
329
330fn fit_non_negative_poisson_linear(
350 observed: &[f64],
351 neutron_signal: &[f64],
352 basis: &[Vec<f64>],
353 initial_amplitudes: &[f64],
354 config: &PoissonConfig,
355) -> Result<LinearPoissonFit, FittingError> {
356 let mut amplitudes = initial_amplitudes.to_vec();
357 for (amplitude, template) in amplitudes.iter_mut().zip(basis) {
358 let upper = amplitude_upper_bound(observed, template)?;
359 *amplitude = amplitude.min(upper);
360 }
361 let mut converged = false;
362 let mut iterations = 0;
363 let mut gradient = vec![0.0; basis.len()];
364
365 for _ in 0..config.max_iter {
366 joint_iteration(
367 observed,
368 neutron_signal,
369 basis,
370 &mut amplitudes,
371 &mut gradient,
372 )?;
373 iterations += 1;
374 if kkt_violation(observed, neutron_signal, basis, &litudes, &mut gradient)?
375 <= config.tol_param
376 {
377 converged = true;
378 break;
379 }
380 }
381 if converged {
382 iterations += polish_active_set(
383 observed,
384 neutron_signal,
385 basis,
386 &mut amplitudes,
387 &mut gradient,
388 config.tol_param,
389 config.max_iter - iterations,
390 )?;
391 }
392
393 let prediction = linear_prediction(neutron_signal, basis, &litudes)?;
394 poisson_gradient_into(observed, &prediction, basis, &mut gradient);
395 let at_bound: Vec<bool> = amplitudes
399 .iter()
400 .zip(&gradient)
401 .map(|(&litude, &slope)| amplitude == 0.0 && slope > 0.0)
402 .collect();
403 let uncertainties = if converged && config.compute_covariance {
404 poisson_linear_uncertainties(&prediction, basis, &at_bound)
405 } else {
406 None
407 };
408 Ok(LinearPoissonFit {
409 amplitudes,
410 uncertainties,
411 at_bound,
412 converged,
413 iterations,
414 })
415}
416
417fn poisson_gradient_into(
418 observed: &[f64],
419 prediction: &[f64],
420 basis: &[Vec<f64>],
421 gradient: &mut [f64],
422) {
423 for (slot, template) in gradient.iter_mut().zip(basis) {
424 *slot = poisson_coordinate_gradient(observed, prediction, template);
425 }
426}
427
428fn joint_iteration(
432 observed: &[f64],
433 neutron_signal: &[f64],
434 basis: &[Vec<f64>],
435 amplitudes: &mut Vec<f64>,
436 gradient: &mut [f64],
437) -> Result<(), FittingError> {
438 let prediction = linear_prediction(neutron_signal, basis, amplitudes)?;
439 poisson_gradient_into(observed, &prediction, basis, gradient);
440 let free: Vec<usize> = (0..basis.len())
441 .filter(|&component| amplitudes[component] > 0.0 || gradient[component] < 0.0)
442 .collect();
443
444 let joint_step =
445 fisher_scoring_direction(&prediction, basis, gradient, &free).and_then(|direction| {
446 projected_backtracking_step(
447 observed,
448 neutron_signal,
449 basis,
450 amplitudes,
451 &prediction,
452 &direction,
453 )
454 });
455 match joint_step {
456 Some(next) => *amplitudes = next,
457 None => {
458 for component in 0..basis.len() {
459 let base = linear_prediction_without_component(
460 neutron_signal,
461 basis,
462 amplitudes,
463 component,
464 )?;
465 amplitudes[component] =
466 coordinate_minimum(observed, &base, &basis[component], amplitudes[component])?;
467 }
468 }
469 }
470 Ok(())
471}
472
473fn kkt_violation(
476 observed: &[f64],
477 neutron_signal: &[f64],
478 basis: &[Vec<f64>],
479 amplitudes: &[f64],
480 gradient: &mut [f64],
481) -> Result<f64, FittingError> {
482 let prediction = linear_prediction(neutron_signal, basis, amplitudes)?;
483 poisson_gradient_into(observed, &prediction, basis, gradient);
484 Ok(basis
485 .iter()
486 .enumerate()
487 .map(|(component, template)| {
488 let violation = if amplitudes[component] == 0.0 {
489 (-gradient[component]).max(0.0)
490 } else {
491 gradient[component].abs()
492 };
493 violation / template.iter().sum::<f64>()
494 })
495 .fold(0.0_f64, f64::max))
496}
497
498fn polish_active_set(
512 observed: &[f64],
513 neutron_signal: &[f64],
514 basis: &[Vec<f64>],
515 amplitudes: &mut Vec<f64>,
516 gradient: &mut [f64],
517 tol_param: f64,
518 budget: usize,
519) -> Result<usize, FittingError> {
520 const POLISH_ROUNDS: usize = 4;
521 const POLISH_ITERATIONS: usize = 8;
522 if budget == 0 {
527 return Ok(0);
528 }
529 let polish_tol = (tol_param * 1.0e-6).max(1.0e-15);
530 let mut extra = 0;
531 for _ in 0..POLISH_ROUNDS {
532 for _ in 0..POLISH_ITERATIONS {
533 if extra == budget
534 || kkt_violation(observed, neutron_signal, basis, amplitudes, gradient)?
535 <= polish_tol
536 {
537 break;
538 }
539 joint_iteration(observed, neutron_signal, basis, amplitudes, gradient)?;
540 extra += 1;
541 }
542 if extra == budget {
543 break;
544 }
545 let mut snapped = false;
546 for component in 0..basis.len() {
547 if amplitudes[component] > 0.0 {
548 let base = linear_prediction_without_component(
549 neutron_signal,
550 basis,
551 amplitudes,
552 component,
553 )?;
554 if poisson_coordinate_gradient_at(observed, &base, &basis[component], 0.0) >= 0.0 {
555 amplitudes[component] = 0.0;
556 snapped = true;
557 }
558 }
559 }
560 if !snapped {
561 break;
562 }
563 }
564 Ok(extra)
565}
566
567fn expected_information(prediction: &[f64], left: &[f64], right: &[f64]) -> f64 {
576 prediction
577 .iter()
578 .zip(left)
579 .zip(right)
580 .filter(|&((&expected, &l), &r)| expected > 0.0 && l != 0.0 && r != 0.0)
581 .map(|((&expected, &l), &r)| {
582 let scale = expected.sqrt();
590 (l / scale) * (r / scale)
591 })
592 .sum()
593}
594
595fn fisher_scoring_direction(
601 prediction: &[f64],
602 basis: &[Vec<f64>],
603 gradient: &[f64],
604 free: &[usize],
605) -> Option<Vec<f64>> {
606 if free.is_empty() {
607 return None;
608 }
609 let mut information = FlatMatrix::zeros(free.len(), free.len());
610 for (row, &j) in free.iter().enumerate() {
611 for (column, &k) in free.iter().enumerate() {
612 *information.get_mut(row, column) =
613 expected_information(prediction, &basis[j], &basis[k]);
614 }
615 }
616 let inverse = invert_matrix(&information)?;
617 let mut direction = vec![0.0; basis.len()];
618 for (row, &j) in free.iter().enumerate() {
619 direction[j] = -(0..free.len())
620 .map(|column| inverse.get(row, column) * gradient[free[column]])
621 .sum::<f64>();
622 }
623 direction
624 .iter()
625 .all(|value| value.is_finite())
626 .then_some(direction)
627}
628
629fn projected_backtracking_step(
634 observed: &[f64],
635 neutron_signal: &[f64],
636 basis: &[Vec<f64>],
637 amplitudes: &[f64],
638 prediction: &[f64],
639 direction: &[f64],
640) -> Option<Vec<f64>> {
641 let current = poisson_deviance(observed, prediction);
642 let mut step = 1.0;
643 for _ in 0..40 {
644 let trial: Vec<f64> = amplitudes
645 .iter()
646 .zip(direction)
647 .map(|(&litude, &delta)| (amplitude + step * delta).max(0.0))
648 .collect();
649 if let Ok(trial_prediction) = linear_prediction(neutron_signal, basis, &trial)
650 && poisson_deviance(observed, &trial_prediction) < current
651 {
652 return Some(trial);
653 }
654 step *= 0.5;
655 }
656 None
657}
658
659fn validate_config(config: &PoissonConfig) -> Result<(), FittingError> {
660 if !config.tol_param.is_finite() || config.tol_param <= 0.0 {
663 return Err(FittingError::InvalidConfig(format!(
664 "tol_param must be finite and > 0, got {}",
665 config.tol_param
666 )));
667 }
668 if config.max_iter == 0 {
672 return Err(FittingError::InvalidConfig(
673 "max_iter must be at least 1".into(),
674 ));
675 }
676 Ok(())
677}
678
679fn coordinate_minimum(
680 observed: &[f64],
681 base: &[f64],
682 template: &[f64],
683 initial: f64,
684) -> Result<f64, FittingError> {
685 let gradient_at_zero = poisson_coordinate_gradient_at(observed, base, template, 0.0);
686 if gradient_at_zero >= 0.0 {
687 return Ok(0.0);
688 }
689
690 let mut upper = amplitude_upper_bound(observed, template)?;
693 if upper <= 0.0 {
694 return Err(FittingError::EvaluationFailed(
695 "could not form a finite upper bound for a background amplitude".into(),
696 ));
697 }
698 let mut lower = 0.0;
699 let mut value = initial.clamp(lower, upper);
700
701 for _ in 0..80 {
705 let (gradient, curvature) =
706 poisson_coordinate_gradient_and_curvature_at(observed, base, template, value);
707 if gradient == 0.0 {
708 return Ok(value);
709 }
710 if gradient < 0.0 {
711 lower = value;
712 } else {
713 upper = value;
714 }
715 let newton = value - gradient / curvature;
716 let next = if curvature.is_finite()
717 && curvature > 0.0
718 && newton.is_finite()
719 && newton > lower
720 && newton < upper
721 {
722 newton
723 } else {
724 lower + 0.5 * (upper - lower)
725 };
726 if next == value {
727 return Ok(next);
728 }
729 value = next;
730 }
731 Ok(value)
732}
733
734fn amplitude_upper_bound(observed: &[f64], template: &[f64]) -> Result<f64, FittingError> {
741 let maximum_observed = observed
742 .iter()
743 .zip(template)
744 .filter_map(|(&count, &weight)| (weight > 0.0).then_some(count))
745 .fold(0.0_f64, f64::max);
746 if maximum_observed == 0.0 {
747 return Ok(0.0);
748 }
749 let scaled_observed_sum: f64 = observed
750 .iter()
751 .zip(template)
752 .filter_map(|(&count, &weight)| (weight > 0.0).then_some(count / maximum_observed))
753 .sum();
754 let template_sum: f64 = template.iter().sum();
755 let upper = maximum_observed * (scaled_observed_sum / template_sum);
756 if upper.is_nan() {
757 return Err(FittingError::EvaluationFailed(
758 "could not form a finite upper bound for a background amplitude".into(),
759 ));
760 }
761 Ok(upper.min(f64::MAX))
766}
767
768fn linear_prediction_without_component(
769 neutron_signal: &[f64],
770 basis: &[Vec<f64>],
771 amplitudes: &[f64],
772 excluded: usize,
773) -> Result<Vec<f64>, FittingError> {
774 let mut prediction = neutron_signal.to_vec();
775 for (component, (&litude, template)) in amplitudes.iter().zip(basis).enumerate() {
776 if component != excluded {
777 add_scaled(&mut prediction, template, amplitude);
778 }
779 }
780 validate_linear_prediction(&prediction)?;
781 Ok(prediction)
782}
783
784fn linear_prediction(
785 neutron_signal: &[f64],
786 basis: &[Vec<f64>],
787 amplitudes: &[f64],
788) -> Result<Vec<f64>, FittingError> {
789 let mut prediction = neutron_signal.to_vec();
790 for (&litude, template) in amplitudes.iter().zip(basis) {
791 add_scaled(&mut prediction, template, amplitude);
792 }
793 validate_linear_prediction(&prediction)?;
794 Ok(prediction)
795}
796
797fn validate_linear_prediction(prediction: &[f64]) -> Result<(), FittingError> {
798 if prediction.iter().any(|value| !value.is_finite()) {
799 return Err(FittingError::EvaluationFailed(
800 "background fit produced non-finite expected counts".into(),
801 ));
802 }
803 Ok(())
804}
805
806fn poisson_coordinate_gradient(observed: &[f64], prediction: &[f64], template: &[f64]) -> f64 {
807 observed
808 .iter()
809 .zip(prediction)
810 .zip(template)
811 .filter(|&((_, _), &weight)| weight > 0.0)
812 .map(|((&count, &expected), &weight)| {
813 weight - weighted_count_ratio(weight, count, expected)
814 })
815 .sum()
816}
817
818fn poisson_coordinate_gradient_at(
819 observed: &[f64],
820 base: &[f64],
821 template: &[f64],
822 amplitude: f64,
823) -> f64 {
824 poisson_coordinate_gradient_and_curvature_at(observed, base, template, amplitude).0
825}
826
827fn poisson_coordinate_gradient_and_curvature_at(
828 observed: &[f64],
829 base: &[f64],
830 template: &[f64],
831 amplitude: f64,
832) -> (f64, f64) {
833 observed.iter().zip(base).zip(template).fold(
834 (0.0, 0.0),
835 |(gradient, curvature), ((&count, &base_count), &weight)| {
836 if weight == 0.0 {
837 return (gradient, curvature);
838 }
839 let expected = base_count + amplitude * weight;
840 let weighted_ratio = weighted_count_ratio(weight, count, expected);
841 let curvature_term = if count == 0.0 {
842 0.0
843 } else {
844 (weight / expected) * weighted_ratio
845 };
846 (
847 gradient + weight - weighted_ratio,
848 curvature + curvature_term,
849 )
850 },
851 )
852}
853
854fn weighted_count_ratio(weight: f64, count: f64, expected: f64) -> f64 {
865 if weight == 0.0 || count == 0.0 {
866 return 0.0;
867 }
868 let ratio = count / expected;
869 if ratio.is_finite() {
870 weight * ratio
871 } else {
872 count * (weight / expected)
873 }
874}
875
876fn poisson_linear_uncertainties(
893 prediction: &[f64],
894 basis: &[Vec<f64>],
895 at_bound: &[bool],
896) -> Option<Vec<f64>> {
897 let support_boundary: Vec<bool> = basis
898 .iter()
899 .map(|template| {
900 prediction
901 .iter()
902 .zip(template)
903 .any(|(&expected, &weight)| expected == 0.0 && weight != 0.0)
904 })
905 .collect();
906 let free: Vec<usize> = (0..basis.len())
907 .filter(|&j| !at_bound[j] && !support_boundary[j])
908 .collect();
909
910 let mut sigma = vec![f64::NAN; basis.len()];
911 if !free.is_empty() {
912 let mut information = FlatMatrix::zeros(free.len(), free.len());
913 for (row, &j) in free.iter().enumerate() {
914 for (column, &k) in free.iter().enumerate() {
915 *information.get_mut(row, column) =
916 expected_information(prediction, &basis[j], &basis[k]);
917 }
918 }
919 let covariance = invert_matrix(&information)?;
920 for (row, &j) in free.iter().enumerate() {
921 sigma[j] = positive_sqrt_or_nan(covariance.get(row, row));
922 }
923 }
924 for (j, template) in basis.iter().enumerate() {
925 if at_bound[j] && !support_boundary[j] {
926 sigma[j] =
927 positive_sqrt_or_nan(1.0 / expected_information(prediction, template, template));
928 }
929 }
930 Some(sigma)
931}
932
933fn positive_sqrt_or_nan(variance: f64) -> f64 {
934 if variance.is_finite() && variance > 0.0 {
935 variance.sqrt()
936 } else {
937 f64::NAN
938 }
939}
940
941fn normalize_templates(
942 templates: &[TwoArmBackgroundTemplate],
943) -> (Vec<TwoArmBackgroundTemplate>, Vec<f64>) {
944 let scales: Vec<f64> = templates
945 .iter()
946 .map(|template| {
947 template
948 .open_beam
949 .iter()
950 .chain(&template.sample)
951 .copied()
952 .fold(0.0_f64, f64::max)
953 })
954 .collect();
955 let normalized = templates
956 .iter()
957 .zip(&scales)
958 .map(|(template, &scale)| TwoArmBackgroundTemplate {
959 name: template.name.clone(),
960 open_beam: template
961 .open_beam
962 .iter()
963 .map(|value| value / scale)
964 .collect(),
965 sample: template.sample.iter().map(|value| value / scale).collect(),
966 })
967 .collect();
968 (normalized, scales)
969}
970
971fn background_template_rank(basis: &[Vec<f64>]) -> usize {
979 let n_rows = basis.first().map_or(0, Vec::len);
980 let dimension = n_rows.max(basis.len()) as f64;
981 let tolerance = f64::EPSILON * dimension * (n_rows as f64).sqrt();
982 let mut orthonormal: Vec<Vec<f64>> = Vec::with_capacity(basis.len());
983
984 for column in basis {
985 let mut residual = column.clone();
986 for _ in 0..2 {
989 for direction in &orthonormal {
990 let projection: f64 = residual.iter().zip(direction).map(|(a, b)| a * b).sum();
991 for (value, &unit_value) in residual.iter_mut().zip(direction) {
992 *value -= projection * unit_value;
993 }
994 }
995 }
996 let norm = residual
997 .iter()
998 .map(|value| value * value)
999 .sum::<f64>()
1000 .sqrt();
1001 if norm > tolerance {
1002 for value in &mut residual {
1003 *value /= norm;
1004 }
1005 orthonormal.push(residual);
1006 }
1007 }
1008 orthonormal.len()
1009}
1010
1011fn add_scaled(output: &mut [f64], template: &[f64], amplitude: f64) {
1012 for (value, &basis) in output.iter_mut().zip(template) {
1013 *value += amplitude * basis;
1014 }
1015}
1016
1017#[allow(clippy::too_many_arguments)]
1018fn validate_fit_inputs(
1019 observed_open_counts: &[f64],
1020 observed_sample_counts: &[f64],
1021 neutron_signal: &TwoArmCounts,
1022 open_exposure_scale: f64,
1023 sample_exposure_scale: f64,
1024 templates: &[TwoArmBackgroundTemplate],
1025 initial_amplitudes: &[f64],
1026) -> Result<(), FittingError> {
1027 let n_bins = observed_open_counts.len();
1028 if n_bins == 0 {
1029 return Err(FittingError::EmptyData);
1030 }
1031 for (field, actual) in [
1032 ("observed_sample_counts", observed_sample_counts.len()),
1033 ("open_neutron_signal", neutron_signal.open_beam.len()),
1034 ("sample_neutron_signal", neutron_signal.sample.len()),
1035 ] {
1036 if actual != n_bins {
1037 return Err(FittingError::LengthMismatch {
1038 expected: n_bins,
1039 actual,
1040 field,
1041 });
1042 }
1043 }
1044 if templates.is_empty() {
1045 return Err(FittingError::InvalidConfig(
1046 "at least one independently supplied background template is required".into(),
1047 ));
1048 }
1049 if initial_amplitudes.len() != templates.len() {
1050 return Err(FittingError::LengthMismatch {
1051 expected: templates.len(),
1052 actual: initial_amplitudes.len(),
1053 field: "initial_amplitudes",
1054 });
1055 }
1056
1057 validate_non_negative("observed_open_counts", observed_open_counts)?;
1058 validate_non_negative("observed_sample_counts", observed_sample_counts)?;
1059 validate_non_negative("open_neutron_signal", &neutron_signal.open_beam)?;
1060 validate_non_negative("sample_neutron_signal", &neutron_signal.sample)?;
1061 for (name, scale) in [
1062 ("open_exposure_scale", open_exposure_scale),
1063 ("sample_exposure_scale", sample_exposure_scale),
1064 ] {
1065 if !scale.is_finite() || scale <= 0.0 {
1066 return Err(FittingError::InvalidConfig(format!(
1067 "{name} must be finite and > 0, got {scale}"
1068 )));
1069 }
1070 }
1071 for (name, value) in [
1075 (
1076 "open_beam_window_loss",
1077 neutron_signal.open_beam_window_loss,
1078 ),
1079 ("sample_window_loss", neutron_signal.sample_window_loss),
1080 ] {
1081 if !value.is_finite() || value < 0.0 {
1082 return Err(FittingError::InvalidConfig(format!(
1083 "{name} must be finite and >= 0 expected counts, got {value}"
1084 )));
1085 }
1086 }
1087
1088 let mut names = HashSet::with_capacity(templates.len());
1089 for (index, (template, &initial)) in templates.iter().zip(initial_amplitudes).enumerate() {
1090 if template.name.trim().is_empty() {
1091 return Err(FittingError::InvalidConfig(format!(
1092 "background template {index} has an empty name"
1093 )));
1094 }
1095 if !names.insert(template.name.as_str()) {
1096 return Err(FittingError::InvalidConfig(format!(
1097 "background template name '{}' is duplicated",
1098 template.name
1099 )));
1100 }
1101 for (field, actual) in [
1102 ("open_background_template", template.open_beam.len()),
1103 ("sample_background_template", template.sample.len()),
1104 ] {
1105 if actual != n_bins {
1106 return Err(FittingError::LengthMismatch {
1107 expected: n_bins,
1108 actual,
1109 field,
1110 });
1111 }
1112 }
1113 validate_non_negative("open_background_template", &template.open_beam)?;
1114 validate_non_negative("sample_background_template", &template.sample)?;
1115 if !template
1116 .open_beam
1117 .iter()
1118 .chain(&template.sample)
1119 .any(|&value| value > 0.0)
1120 {
1121 return Err(FittingError::InvalidConfig(format!(
1122 "background template '{}' is zero in both arms",
1123 template.name
1124 )));
1125 }
1126 if !initial.is_finite() || initial < 0.0 {
1127 return Err(FittingError::InvalidConfig(format!(
1128 "initial_amplitudes[{index}] must be finite and >= 0, got {initial}"
1129 )));
1130 }
1131 }
1132 Ok(())
1133}
1134
1135fn validate_non_negative(field: &'static str, values: &[f64]) -> Result<(), FittingError> {
1136 for (index, &value) in values.iter().enumerate() {
1137 if !value.is_finite() || value < 0.0 {
1138 return Err(FittingError::InvalidConfig(format!(
1139 "{field}[{index}] must be finite and >= 0 expected counts, got {value}"
1140 )));
1141 }
1142 }
1143 Ok(())
1144}
1145
1146fn poisson_deviance(observed: &[f64], predicted: &[f64]) -> f64 {
1147 observed
1148 .iter()
1149 .zip(predicted)
1150 .map(|(&obs, &model)| {
1151 if obs > 0.0 {
1152 if model == 0.0 {
1153 return f64::INFINITY;
1154 }
1155 let r = (obs - model) / model;
1159 let deviance = if r.abs() < 1.0e-3 {
1160 let h = r
1161 * r
1162 * (0.5
1163 + r * (-1.0 / 6.0
1164 + r * (1.0 / 12.0 + r * (-1.0 / 20.0 + r * (1.0 / 30.0)))));
1165 2.0 * (model * h)
1169 } else {
1170 2.0 * (obs * (obs.ln() - model.ln()) - (obs - model))
1174 };
1175 deviance.max(0.0)
1178 } else {
1179 2.0 * model
1180 }
1181 })
1182 .sum()
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187 use super::{count_informative_bins, expected_information, poisson_deviance};
1188
1189 #[test]
1190 fn exact_zero_observation_and_prediction_have_zero_deviance() {
1191 assert_eq!(poisson_deviance(&[0.0], &[0.0]), 0.0);
1192 assert!(poisson_deviance(&[1.0], &[0.0]).is_infinite());
1193 }
1194
1195 #[test]
1196 fn nearly_equal_large_counts_have_non_negative_deviance() {
1197 let observed = [31_415_926_535_897.0, 27_182_818_284_590.0];
1198 let predicted = [31_415_926_535_896.0, 27_182_818_284_592.0];
1199 let deviance = poisson_deviance(&observed, &predicted);
1200 assert!(deviance.is_finite());
1201 assert!(deviance >= 0.0, "deviance = {deviance}");
1202 }
1203
1204 #[test]
1211 fn expected_information_is_symmetric_and_finite_at_subnormal_expectation() {
1212 let forward = expected_information(&[1.0e-320], &[1.0], &[1.0e-320]);
1213 let reversed = expected_information(&[1.0e-320], &[1.0e-320], &[1.0]);
1214 assert!(forward.is_finite(), "forward = {forward}");
1215 assert_eq!(forward, reversed);
1216 assert!((forward - 1.0).abs() < 1.0e-9, "forward = {forward}");
1217 }
1218
1219 #[test]
1220 fn near_equality_extreme_counts_keep_finite_deviance() {
1221 let deviance = poisson_deviance(&[1.0001e308], &[1.0e308]);
1222 assert!(
1223 deviance.is_finite() && deviance > 0.0,
1224 "deviance = {deviance}"
1225 );
1226 }
1227
1228 #[test]
1229 fn extreme_finite_count_ratios_keep_finite_deviance() {
1230 let low_observation = poisson_deviance(&[1.0e-200], &[1.0e200]);
1231 let high_observation = poisson_deviance(&[1.0e200], &[1.0e-200]);
1232 assert!(low_observation.is_finite() && low_observation >= 0.0);
1233 assert!(high_observation.is_finite() && high_observation >= 0.0);
1234 }
1235
1236 #[test]
1241 fn only_bins_that_can_discriminate_are_informative() {
1242 let basis = vec![vec![0.0, 1.0, 0.0, 0.0]];
1243 let observed = [0.0, 5.0, 0.0, 3.0];
1244 let neutron = [0.0, 2.0, 0.0, 0.0];
1245 assert_eq!(count_informative_bins(&observed, &neutron, &basis), 2);
1246
1247 let all_dead = [0.0, 0.0];
1248 assert_eq!(
1249 count_informative_bins(&all_dead, &all_dead, &[vec![0.0, 0.0]]),
1250 0
1251 );
1252 }
1253}