1use serde::{Deserialize, Serialize};
127use std::f64::consts::PI;
128
129#[derive(Debug, Clone, PartialEq, thiserror::Error)]
131pub enum PsfError {
132 #[error("invalid radial binning: r_min = {r_min} m, r_max = {r_max} m, bins = {bins} (need finite 0 < r_min < r_max and bins > 0)")]
134 Binning {
135 r_min: f64,
137 r_max: f64,
139 bins: usize,
141 },
142 #[error("invalid depth slab [{lo}, {hi}) m: need finite lo < hi")]
144 Slab {
145 lo: f64,
147 hi: f64,
149 },
150 #[error("invalid radial profile: {0}")]
152 Profile(String),
153 #[error("{used} usable bins for {parameters} parameters: need at least parameters + 1")]
155 TooFewBins {
156 used: usize,
158 parameters: usize,
160 },
161 #[error("no start with positive term amplitudes was found")]
163 NoStart,
164 #[error("the curvature matrix is singular; the parameters are not determined by the profile")]
166 Singular,
167 #[error("invalid PSF parameters: {0}")]
169 Parameters(String),
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
175pub struct LogRadialBinning {
176 pub r_min_m: f64,
178 pub r_max_m: f64,
180 pub bins: usize,
182}
183
184impl LogRadialBinning {
185 pub fn new(r_min_m: f64, r_max_m: f64, bins: usize) -> Result<Self, PsfError> {
187 let b = Self {
188 r_min_m,
189 r_max_m,
190 bins,
191 };
192 b.validate()?;
193 Ok(b)
194 }
195
196 fn validate(&self) -> Result<(), PsfError> {
197 if self.r_min_m.is_finite()
198 && self.r_max_m.is_finite()
199 && self.r_min_m > 0.0
200 && self.r_max_m > self.r_min_m
201 && self.bins > 0
202 {
203 Ok(())
204 } else {
205 Err(PsfError::Binning {
206 r_min: self.r_min_m,
207 r_max: self.r_max_m,
208 bins: self.bins,
209 })
210 }
211 }
212
213 pub fn len(&self) -> usize {
215 self.bins + 1
216 }
217
218 pub fn is_empty(&self) -> bool {
220 false
221 }
222
223 pub fn edge(&self, i: usize) -> f64 {
226 if i == 0 {
227 0.0
228 } else if i > self.bins {
229 self.r_max_m
230 } else {
231 let t = (i - 1) as f64 / self.bins as f64;
232 self.r_min_m * (self.r_max_m / self.r_min_m).powf(t)
233 }
234 }
235
236 pub fn edges(&self) -> Vec<f64> {
238 (0..=self.len()).map(|i| self.edge(i)).collect()
239 }
240
241 pub fn locate(&self, r: f64) -> Option<usize> {
243 if r < self.r_min_m {
244 return Some(0);
245 }
246 if r.is_nan() || r >= self.r_max_m {
247 return None;
248 }
249 let t = (r / self.r_min_m).ln() / (self.r_max_m / self.r_min_m).ln();
250 let i = (t * self.bins as f64) as usize;
251 Some(1 + i.min(self.bins - 1))
252 }
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
258pub struct PsfConfig {
259 pub radial: LogRadialBinning,
261 pub depth_lo_m: f64,
263 pub depth_hi_m: f64,
265}
266
267impl PsfConfig {
268 pub fn new(
270 radial: LogRadialBinning,
271 depth_lo_m: f64,
272 depth_hi_m: f64,
273 ) -> Result<Self, PsfError> {
274 let c = Self {
275 radial,
276 depth_lo_m,
277 depth_hi_m,
278 };
279 c.validate()?;
280 Ok(c)
281 }
282
283 pub fn validate(&self) -> Result<(), PsfError> {
285 self.radial.validate()?;
286 if self.depth_lo_m.is_finite()
287 && self.depth_hi_m.is_finite()
288 && self.depth_hi_m > self.depth_lo_m
289 {
290 Ok(())
291 } else {
292 Err(PsfError::Slab {
293 lo: self.depth_lo_m,
294 hi: self.depth_hi_m,
295 })
296 }
297 }
298}
299
300#[derive(Debug, Clone, PartialEq)]
305pub struct RadialAccumulator {
306 config: PsfConfig,
307 histories: u64,
308 sum: Vec<f64>,
311 sum_sq: Vec<f64>,
313 scratch: Vec<f64>,
315 touched: Vec<usize>,
318}
319
320impl RadialAccumulator {
321 pub fn new(config: PsfConfig) -> Result<Self, PsfError> {
323 config.validate()?;
324 let n = config.radial.len() + 2;
325 Ok(Self {
326 config,
327 histories: 0,
328 sum: vec![0.0; n],
329 sum_sq: vec![0.0; n],
330 scratch: vec![0.0; n],
331 touched: Vec::new(),
332 })
333 }
334
335 pub fn config(&self) -> &PsfConfig {
337 &self.config
338 }
339
340 pub fn histories(&self) -> u64 {
342 self.histories
343 }
344
345 pub fn deposit(&mut self, pos: [f64; 3], e: f64) {
348 let in_slab = pos[0] >= self.config.depth_lo_m && pos[0] < self.config.depth_hi_m;
349 if !(e.is_finite() && e > 0.0 && in_slab) {
350 return;
351 }
352 let n = self.config.radial.len();
353 let i = self.config.radial.locate(pos[1].hypot(pos[2])).unwrap_or(n);
354 if self.scratch[i] == 0.0 {
355 self.touched.push(i);
356 }
357 self.scratch[i] += e;
358 self.scratch[n + 1] += e;
359 }
360
361 pub fn end_history(&mut self) {
363 self.histories += 1;
364 self.touched.sort_unstable();
366 for &i in &self.touched {
367 let x = self.scratch[i];
368 self.sum[i] += x;
369 self.sum_sq[i] += x * x;
370 self.scratch[i] = 0.0;
371 }
372 self.touched.clear();
373 let t = self.scratch.len() - 1;
374 let x = self.scratch[t];
375 if x != 0.0 {
376 self.sum[t] += x;
377 self.sum_sq[t] += x * x;
378 self.scratch[t] = 0.0;
379 }
380 }
381
382 pub fn merge(&mut self, o: &RadialAccumulator) {
387 assert!(
388 self.config == o.config,
389 "merging radial accumulators with different configurations"
390 );
391 self.histories += o.histories;
392 for (a, b) in self.sum.iter_mut().zip(&o.sum) {
393 *a += b;
394 }
395 for (a, b) in self.sum_sq.iter_mut().zip(&o.sum_sq) {
396 *a += b;
397 }
398 }
399
400 pub fn profile(&self) -> RadialProfile {
402 let n = self.config.radial.len();
403 let se = |i: usize| std_err_of_sum(self.sum[i], self.sum_sq[i], self.histories);
404 RadialProfile {
405 histories: self.histories,
406 depth_lo_m: self.config.depth_lo_m,
407 depth_hi_m: self.config.depth_hi_m,
408 edges_m: self.config.radial.edges()[..=n].to_vec(),
409 energy_ev: self.sum[..n].to_vec(),
410 std_err_ev: (0..n).map(se).collect(),
411 beyond_ev: self.sum[n],
412 beyond_std_err_ev: se(n),
413 total_ev: self.sum[n + 1],
414 total_std_err_ev: se(n + 1),
415 }
416 }
417}
418
419fn std_err_of_sum(s: f64, q: f64, n: u64) -> f64 {
422 if n < 2 {
423 return 0.0;
424 }
425 let nf = n as f64;
426 (nf / (nf - 1.0) * (q - s * s / nf)).max(0.0).sqrt()
427}
428
429#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
433pub struct RadialProfile {
434 pub histories: u64,
436 pub depth_lo_m: f64,
438 pub depth_hi_m: f64,
440 pub edges_m: Vec<f64>,
442 pub energy_ev: Vec<f64>,
444 pub std_err_ev: Vec<f64>,
446 pub beyond_ev: f64,
448 pub beyond_std_err_ev: f64,
450 pub total_ev: f64,
452 pub total_std_err_ev: f64,
454}
455
456impl RadialProfile {
457 pub const TOTAL_REL_TOLERANCE: f64 = 1e-6;
466
467 pub fn validate(&self) -> Result<(), PsfError> {
474 let bad = |s: &str| Err(PsfError::Profile(s.to_string()));
475 let n = self.energy_ev.len();
476 if n == 0 || self.edges_m.len() != n + 1 || self.std_err_ev.len() != n {
477 return bad("need n >= 1 bins with n + 1 edges, n energies and n errors");
478 }
479 if self.edges_m[0] != 0.0 || !self.edges_m.windows(2).all(|w| w[1] > w[0]) {
480 return bad("edges must start at 0 and increase");
481 }
482 if !self.edges_m.iter().all(|e| e.is_finite())
483 || !self.energy_ev.iter().all(|e| e.is_finite())
484 || !self.std_err_ev.iter().all(|e| e.is_finite() && *e >= 0.0)
485 {
486 return bad("edges, energies and errors must be finite, errors non-negative");
487 }
488 if !(self.depth_lo_m.is_finite()
489 && self.depth_hi_m.is_finite()
490 && self.depth_lo_m < self.depth_hi_m)
491 {
492 return bad("depth bounds must be finite with depth_lo_m < depth_hi_m");
493 }
494 let scalars = [
495 self.beyond_ev,
496 self.beyond_std_err_ev,
497 self.total_ev,
498 self.total_std_err_ev,
499 ];
500 if !scalars.iter().all(|v| v.is_finite() && *v >= 0.0) {
501 return bad(
502 "beyond_ev, beyond_std_err_ev, total_ev and total_std_err_ev \
503 must be finite and non-negative",
504 );
505 }
506 let parts = self.energy_ev.iter().sum::<f64>() + self.beyond_ev;
507 let scale =
508 self.energy_ev.iter().map(|e| e.abs()).sum::<f64>() + self.beyond_ev + self.total_ev;
509 if (self.total_ev - parts).abs() > Self::TOTAL_REL_TOLERANCE * scale {
510 return bad("total_ev must equal the bin energies plus beyond_ev");
511 }
512 Ok(())
513 }
514
515 pub fn len(&self) -> usize {
517 self.energy_ev.len()
518 }
519
520 pub fn is_empty(&self) -> bool {
522 self.energy_ev.is_empty()
523 }
524
525 pub fn area_m2(&self, i: usize) -> f64 {
527 let (a, b) = (self.edges_m[i], self.edges_m[i + 1]);
528 PI * (b * b - a * a)
529 }
530
531 pub fn center_m(&self, i: usize) -> f64 {
534 let (a, b) = (self.edges_m[i], self.edges_m[i + 1]);
535 if a > 0.0 {
536 (a * b).sqrt()
537 } else {
538 0.5 * b
539 }
540 }
541
542 pub fn density_ev_per_m2(&self) -> Vec<f64> {
544 (0..self.len())
545 .map(|i| self.energy_ev[i] / self.area_m2(i))
546 .collect()
547 }
548
549 pub fn density_std_err_ev_per_m2(&self) -> Vec<f64> {
551 (0..self.len())
552 .map(|i| self.std_err_ev[i] / self.area_m2(i))
553 .collect()
554 }
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
559pub enum PsfModel {
560 DoubleGaussian,
562 TripleGaussian,
565}
566
567impl PsfModel {
568 pub fn terms(&self) -> usize {
570 match self {
571 PsfModel::DoubleGaussian => 2,
572 PsfModel::TripleGaussian => 3,
573 }
574 }
575
576 pub fn parameters(&self) -> usize {
578 2 * self.terms()
579 }
580
581 pub fn parameter_names(&self) -> Vec<&'static str> {
583 match self {
584 PsfModel::DoubleGaussian => vec!["total_ev", "alpha_m", "beta_m", "eta"],
585 PsfModel::TripleGaussian => {
586 vec!["total_ev", "alpha_m", "beta_m", "eta", "gamma_m", "nu"]
587 }
588 }
589 }
590
591 pub fn source(&self) -> &'static str {
593 match self {
594 PsfModel::DoubleGaussian => {
595 "f(r) = [exp(-r^2/alpha^2)/alpha^2 + eta exp(-r^2/beta^2)/beta^2] / (pi (1 + eta)): \
596 Mao, Zhu, Cheng and Wang, Discover Nano 20, 84 (2025), doi:10.1186/s11671-025-04264-0, \
597 eq. (1), after Chang, J. Vac. Sci. Technol. 12, 1271 (1975), doi:10.1116/1.568515"
598 }
599 PsfModel::TripleGaussian => {
600 "f(r) = [exp(-r^2/alpha^2)/alpha^2 + eta exp(-r^2/beta^2)/beta^2 + nu exp(-r^2/gamma^2)/gamma^2] \
601 / (pi (1 + eta + nu)): Rosa Figueiro, PhD thesis, Univ. Grenoble Alpes (2015), \
602 HAL tel-01206934, eq. (72) with two backscatter terms (beta_1 = beta, eta_1 = eta, \
603 beta_2 = gamma, eta_2 = nu)"
604 }
605 }
606 }
607}
608
609#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
613pub struct GaussianPsf {
614 pub total_ev: f64,
616 pub alpha_m: f64,
618 pub beta_m: f64,
620 pub eta: f64,
622 pub gamma_m: Option<f64>,
624 pub nu: Option<f64>,
626}
627
628impl GaussianPsf {
629 pub fn double(total_ev: f64, alpha_m: f64, beta_m: f64, eta: f64) -> Result<Self, PsfError> {
631 let p = Self {
632 total_ev,
633 alpha_m,
634 beta_m,
635 eta,
636 gamma_m: None,
637 nu: None,
638 };
639 p.validate()?;
640 Ok(p)
641 }
642
643 pub fn triple(
645 total_ev: f64,
646 alpha_m: f64,
647 beta_m: f64,
648 eta: f64,
649 gamma_m: f64,
650 nu: f64,
651 ) -> Result<Self, PsfError> {
652 let p = Self {
653 total_ev,
654 alpha_m,
655 beta_m,
656 eta,
657 gamma_m: Some(gamma_m),
658 nu: Some(nu),
659 };
660 p.validate()?;
661 Ok(p)
662 }
663
664 pub fn model(&self) -> PsfModel {
666 if self.gamma_m.is_some() {
667 PsfModel::TripleGaussian
668 } else {
669 PsfModel::DoubleGaussian
670 }
671 }
672
673 pub fn validate(&self) -> Result<(), PsfError> {
676 if self.gamma_m.is_some() != self.nu.is_some() {
677 return Err(PsfError::Parameters(
678 "gamma_m and nu must both be set or both unset".into(),
679 ));
680 }
681 if self.values().iter().all(|v| v.is_finite() && *v > 0.0) {
682 Ok(())
683 } else {
684 Err(PsfError::Parameters(format!(
685 "every parameter must be positive and finite, got {:?}",
686 self.values()
687 )))
688 }
689 }
690
691 pub fn values(&self) -> Vec<f64> {
693 let mut v = vec![self.total_ev, self.alpha_m, self.beta_m, self.eta];
694 if let (Some(g), Some(n)) = (self.gamma_m, self.nu) {
695 v.extend([g, n]);
696 }
697 v
698 }
699
700 fn from_values(model: PsfModel, v: &[f64]) -> Self {
701 Self {
702 total_ev: v[0],
703 alpha_m: v[1],
704 beta_m: v[2],
705 eta: v[3],
706 gamma_m: (model == PsfModel::TripleGaussian).then(|| v[4]),
707 nu: (model == PsfModel::TripleGaussian).then(|| v[5]),
708 }
709 }
710
711 fn terms(&self) -> (Vec<f64>, Vec<f64>) {
713 let mut w = vec![self.alpha_m, self.beta_m];
714 let mut c = vec![1.0, self.eta];
715 if let (Some(g), Some(n)) = (self.gamma_m, self.nu) {
716 w.push(g);
717 c.push(n);
718 }
719 (w, c)
720 }
721
722 pub fn normalized_per_m2(&self, r_m: f64) -> f64 {
724 let (w, c) = self.terms();
725 let sum_c: f64 = c.iter().sum();
726 let s: f64 = w
727 .iter()
728 .zip(&c)
729 .map(|(w, c)| c / (w * w) * (-(r_m * r_m) / (w * w)).exp())
730 .sum();
731 s / (PI * sum_c)
732 }
733
734 pub fn density_ev_per_m2(&self, r_m: f64) -> f64 {
736 self.total_ev * self.normalized_per_m2(r_m)
737 }
738
739 pub fn annulus_energy_ev(&self, a_m: f64, b_m: f64) -> f64 {
742 let (w, c) = self.terms();
743 let sum_c: f64 = c.iter().sum();
744 let s: f64 = w
745 .iter()
746 .zip(&c)
747 .map(|(w, c)| c * gauss_annulus(a_m, b_m, *w))
748 .sum();
749 self.total_ev * s / sum_c
750 }
751}
752
753fn gauss_annulus(a: f64, b: f64, w: f64) -> f64 {
757 let w2 = w * w;
758 -(-(a * a) / w2).exp() * (-(b * b - a * a) / w2).exp_m1()
759}
760
761fn gauss_annulus_dw(a: f64, b: f64, w: f64) -> f64 {
763 let w2 = w * w;
764 2.0 / (w2 * w) * (a * a * (-(a * a) / w2).exp() - b * b * (-(b * b) / w2).exp())
765}
766
767#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
769pub enum PsfNormalization {
770 #[default]
776 SlabTotal,
777 Free,
783}
784
785#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
787pub struct PsfFitOptions {
788 pub normalization: PsfNormalization,
790 pub start: Option<GaussianPsf>,
794 pub grid_points: usize,
797 pub max_iterations: usize,
799}
800
801impl Default for PsfFitOptions {
802 fn default() -> Self {
803 Self {
804 normalization: PsfNormalization::SlabTotal,
805 start: None,
806 grid_points: 40,
807 max_iterations: 500,
808 }
809 }
810}
811
812#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
814pub struct PsfResidual {
815 pub r_lo_m: f64,
817 pub r_hi_m: f64,
819 pub observed_ev: f64,
821 pub model_ev: f64,
823 pub std_err_ev: f64,
825 pub normalized: Option<f64>,
828}
829
830#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
832pub struct PsfFit {
833 pub model: PsfModel,
835 pub source: String,
837 pub method: String,
839 pub normalization: PsfNormalization,
841 pub psf: GaussianPsf,
843 pub parameter_names: Vec<String>,
846 pub values: Vec<f64>,
848 pub std_errors: Vec<f64>,
850 pub covariance: Vec<Vec<f64>>,
856 pub chi2: f64,
858 pub dof: usize,
860 pub reduced_chi2: f64,
862 pub bins_used: usize,
864 pub iterations: usize,
866 pub converged: bool,
869 pub residuals: Vec<PsfResidual>,
871}
872
873impl PsfFit {
874 pub fn std_error(&self, name: &str) -> Option<f64> {
876 self.parameter_names
877 .iter()
878 .position(|n| n == name)
879 .map(|i| self.std_errors[i])
880 }
881}
882
883pub const PSF_FIT_METHOD: &str = "weighted least squares on the bin energies (not their logarithm), \
885 model = exact annulus integral of E f(r), weights 1/sigma_i^2 from per-history errors, bins with \
886 sigma_i = 0 left out; start from a log grid over the widths with linear least squares for the \
887 amplitudes; Levenberg-Marquardt on the logarithms of the parameters with the analytic Jacobian; \
888 covariance (J^T W J)^-1 over the fitted parameters in their natural units, not scaled by the \
889 reduced chi^2; E fixed to the slab energy (default) or fitted, see normalization";
890
891struct FitData {
893 a: Vec<f64>,
894 b: Vec<f64>,
895 y: Vec<f64>,
896 sigma: Vec<f64>,
897}
898
899impl FitData {
900 fn chi2(&self, model: PsfModel, p: &[f64]) -> f64 {
901 let psf = GaussianPsf::from_values(model, p);
902 (0..self.y.len())
903 .map(|i| {
904 let r = (self.y[i] - psf.annulus_energy_ev(self.a[i], self.b[i])) / self.sigma[i];
905 r * r
906 })
907 .sum()
908 }
909
910 fn residuals_and_jacobian(&self, model: PsfModel, p: &[f64]) -> (Vec<f64>, Vec<f64>) {
913 let k = model.terms();
914 let np = model.parameters();
915 let e = p[0];
916 let mut w = vec![p[1]];
917 let mut c = vec![1.0];
918 for t in 1..k {
919 w.push(p[2 * t]);
920 c.push(p[2 * t + 1]);
921 }
922 let sum_c: f64 = c.iter().sum();
923 let n = self.y.len();
924 let mut r = vec![0.0; n];
925 let mut jac = vec![0.0; n * np];
926 for i in 0..n {
927 let g: Vec<f64> = w
928 .iter()
929 .map(|w| gauss_annulus(self.a[i], self.b[i], *w))
930 .collect();
931 let m: f64 = g.iter().zip(&c).map(|(g, c)| g * c).sum::<f64>() / sum_c;
932 let s = self.sigma[i];
933 r[i] = (self.y[i] - e * m) / s;
934 let row = &mut jac[i * np..(i + 1) * np];
935 row[0] = m / s;
936 for t in 0..k {
937 let dw = gauss_annulus_dw(self.a[i], self.b[i], w[t]);
938 let iw = if t == 0 { 1 } else { 2 * t };
939 row[iw] = e * c[t] * dw / sum_c / s;
940 if t > 0 {
941 row[2 * t + 1] = e * (g[t] - m) / sum_c / s;
942 }
943 }
944 }
945 (r, jac)
946 }
947}
948
949pub fn fit_psf(
952 profile: &RadialProfile,
953 model: PsfModel,
954 options: &PsfFitOptions,
955) -> Result<PsfFit, PsfError> {
956 profile.validate()?;
957 let np = model.parameters();
958 let free: Vec<usize> = match options.normalization {
960 PsfNormalization::Free => (0..np).collect(),
961 PsfNormalization::SlabTotal => (1..np).collect(),
962 };
963 let nf = free.len();
964 let used: Vec<usize> = (0..profile.len())
965 .filter(|&i| profile.std_err_ev[i] > 0.0)
966 .collect();
967 if used.len() < nf + 1 {
968 return Err(PsfError::TooFewBins {
969 used: used.len(),
970 parameters: nf,
971 });
972 }
973 if options.normalization == PsfNormalization::SlabTotal && profile.total_ev <= 0.0 {
974 return Err(PsfError::Profile(
975 "no energy in the slab to normalise the PSF to".into(),
976 ));
977 }
978 let data = FitData {
979 a: used.iter().map(|&i| profile.edges_m[i]).collect(),
980 b: used.iter().map(|&i| profile.edges_m[i + 1]).collect(),
981 y: used.iter().map(|&i| profile.energy_ev[i]).collect(),
982 sigma: used.iter().map(|&i| profile.std_err_ev[i]).collect(),
983 };
984 let mut start = match options.start {
985 Some(s) => {
986 s.validate()?;
987 if s.model() != model {
988 return Err(PsfError::Parameters(
989 "the start's model differs from the fitted model".into(),
990 ));
991 }
992 s.values()
993 }
994 None => grid_start(profile, &data, model, options.grid_points.max(2))?,
995 };
996 if options.normalization == PsfNormalization::SlabTotal {
997 start[0] = profile.total_ev;
998 }
999
1000 let mut p = start;
1002 let mut chi2 = data.chi2(model, &p);
1003 let mut lambda = 1e-3;
1004 let mut iterations = 0;
1005 let mut converged = false;
1006 while iterations < options.max_iterations {
1007 iterations += 1;
1008 let (r, jac) = data.residuals_and_jacobian(model, &p);
1009 let mut a = vec![0.0; nf * nf];
1011 let mut g = vec![0.0; nf];
1012 for (i, ri) in r.iter().enumerate() {
1013 let row = &jac[i * np..(i + 1) * np];
1014 for (j, &fj) in free.iter().enumerate() {
1015 let jj = row[fj] * p[fj];
1016 g[j] += jj * ri;
1017 for (k, &fk) in free.iter().enumerate().take(j + 1) {
1018 a[j * nf + k] += jj * row[fk] * p[fk];
1019 }
1020 }
1021 }
1022 for j in 0..nf {
1023 for k in 0..j {
1024 a[k * nf + j] = a[j * nf + k];
1025 }
1026 }
1027 let mut improved = false;
1028 while lambda <= 1e16 {
1029 let mut m = a.clone();
1030 for j in 0..nf {
1031 m[j * nf + j] += lambda * a[j * nf + j].max(f64::MIN_POSITIVE);
1032 }
1033 let Some(delta) = solve(m, g.clone(), nf) else {
1034 lambda *= 10.0;
1035 continue;
1036 };
1037 let mut pt = p.clone();
1038 for (d, &fj) in delta.iter().zip(&free) {
1039 pt[fj] = (p[fj].ln() + d).exp();
1040 }
1041 let c2 = data.chi2(model, &pt);
1042 if pt.iter().all(|v| v.is_finite() && *v > 0.0) && c2.is_finite() && c2 < chi2 {
1043 let step = delta.iter().fold(0.0f64, |m, d| m.max(d.abs()));
1044 let rel = (chi2 - c2) / c2.max(f64::MIN_POSITIVE);
1045 p = pt;
1046 chi2 = c2;
1047 lambda = (lambda * 0.1).max(1e-12);
1048 improved = true;
1049 if step < 1e-10 || rel < 1e-13 {
1050 converged = true;
1051 }
1052 break;
1053 }
1054 lambda *= 10.0;
1055 }
1056 if !improved {
1057 converged = true;
1059 }
1060 if converged {
1061 break;
1062 }
1063 }
1064
1065 let (_, jac) = data.residuals_and_jacobian(model, &p);
1067 let n = data.y.len();
1068 let mut a = vec![0.0; nf * nf];
1069 for i in 0..n {
1070 let row = &jac[i * np..(i + 1) * np];
1071 for (j, &fj) in free.iter().enumerate() {
1072 for (k, &fk) in free.iter().enumerate() {
1073 a[j * nf + k] += row[fj] * row[fk];
1074 }
1075 }
1076 }
1077 let inv = invert(a, nf).ok_or(PsfError::Singular)?;
1078 let mut covariance = vec![vec![0.0; np]; np];
1079 for (j, &fj) in free.iter().enumerate() {
1080 for (k, &fk) in free.iter().enumerate() {
1081 covariance[fj][fk] = inv[j * nf + k];
1082 }
1083 }
1084 if options.normalization == PsfNormalization::SlabTotal {
1085 covariance[0][0] = profile.total_std_err_ev * profile.total_std_err_ev;
1086 }
1087 if !(0..np).all(|j| covariance[j][j].is_finite())
1091 || !free.iter().all(|&j| covariance[j][j] > 0.0)
1092 {
1093 return Err(PsfError::Singular);
1094 }
1095 let std_errors = (0..np).map(|j| covariance[j][j].sqrt()).collect();
1096 let psf = GaussianPsf::from_values(model, &p);
1097 let residuals = (0..profile.len())
1098 .map(|i| {
1099 let (lo, hi) = (profile.edges_m[i], profile.edges_m[i + 1]);
1100 let model_ev = psf.annulus_energy_ev(lo, hi);
1101 let s = profile.std_err_ev[i];
1102 PsfResidual {
1103 r_lo_m: lo,
1104 r_hi_m: hi,
1105 observed_ev: profile.energy_ev[i],
1106 model_ev,
1107 std_err_ev: s,
1108 normalized: (s > 0.0).then(|| (profile.energy_ev[i] - model_ev) / s),
1109 }
1110 })
1111 .collect();
1112 let dof = n - nf;
1113 Ok(PsfFit {
1114 model,
1115 source: model.source().to_string(),
1116 method: PSF_FIT_METHOD.to_string(),
1117 normalization: options.normalization,
1118 psf,
1119 parameter_names: model
1120 .parameter_names()
1121 .into_iter()
1122 .map(String::from)
1123 .collect(),
1124 values: p,
1125 std_errors,
1126 covariance,
1127 chi2,
1128 dof,
1129 reduced_chi2: chi2 / dof as f64,
1130 bins_used: n,
1131 iterations,
1132 converged,
1133 residuals,
1134 })
1135}
1136
1137fn grid_start(
1140 profile: &RadialProfile,
1141 data: &FitData,
1142 model: PsfModel,
1143 points: usize,
1144) -> Result<Vec<f64>, PsfError> {
1145 let k = model.terms();
1146 let lo = profile.edges_m[1];
1148 let hi = (0.5 * profile.edges_m[profile.len()]).max(lo * 1.0001);
1149 let widths: Vec<f64> = (0..points)
1150 .map(|i| lo * (hi / lo).powf(i as f64 / (points - 1) as f64))
1151 .collect();
1152 let n = data.y.len();
1153 let cols: Vec<Vec<f64>> = widths
1155 .iter()
1156 .map(|&w| {
1157 (0..n)
1158 .map(|i| gauss_annulus(data.a[i], data.b[i], w) / data.sigma[i])
1159 .collect()
1160 })
1161 .collect();
1162 let yw: Vec<f64> = (0..n).map(|i| data.y[i] / data.sigma[i]).collect();
1163 let mut best: Option<(f64, Vec<usize>, Vec<f64>)> = None;
1164 let mut idx = vec![0usize; k];
1165 let mut consider = |idx: &[usize]| {
1166 let mut m = vec![0.0; k * k];
1167 let mut rhs = vec![0.0; k];
1168 for i in 0..n {
1169 for j in 0..k {
1170 let cj = cols[idx[j]][i];
1171 rhs[j] += cj * yw[i];
1172 for l in 0..k {
1173 m[j * k + l] += cj * cols[idx[l]][i];
1174 }
1175 }
1176 }
1177 let Some(amp) = solve(m, rhs, k) else {
1178 return;
1179 };
1180 if !amp.iter().all(|a| a.is_finite() && *a > 0.0) {
1181 return;
1182 }
1183 let chi2: f64 = (0..n)
1184 .map(|i| {
1185 let mu: f64 = (0..k).map(|j| amp[j] * cols[idx[j]][i]).sum();
1186 (yw[i] - mu).powi(2)
1187 })
1188 .sum();
1189 if best.as_ref().is_none_or(|b| chi2 < b.0) {
1190 best = Some((chi2, idx.to_vec(), amp));
1191 }
1192 };
1193 fn next(idx: &mut [usize], points: usize) -> bool {
1195 let k = idx.len();
1196 let mut j = k;
1197 while j > 0 {
1198 j -= 1;
1199 if idx[j] < points - (k - j) {
1200 idx[j] += 1;
1201 for l in j + 1..k {
1202 idx[l] = idx[l - 1] + 1;
1203 }
1204 return true;
1205 }
1206 }
1207 false
1208 }
1209 if points < k {
1210 return Err(PsfError::NoStart);
1211 }
1212 for (j, v) in idx.iter_mut().enumerate() {
1213 *v = j;
1214 }
1215 loop {
1216 consider(&idx);
1217 if !next(&mut idx, points) {
1218 break;
1219 }
1220 }
1221 let (_, idx, amp) = best.ok_or(PsfError::NoStart)?;
1222 let e: f64 = amp.iter().sum();
1224 let mut v = vec![e, widths[idx[0]]];
1225 for t in 1..k {
1226 v.push(widths[idx[t]]);
1227 v.push(amp[t] / amp[0]);
1228 }
1229 Ok(v)
1230}
1231
1232fn solve(mut m: Vec<f64>, mut r: Vec<f64>, n: usize) -> Option<Vec<f64>> {
1235 for c in 0..n {
1236 let piv = (c..n).max_by(|&i, &j| m[i * n + c].abs().total_cmp(&m[j * n + c].abs()))?;
1237 if m[piv * n + c] == 0.0 || !m[piv * n + c].is_finite() {
1238 return None;
1239 }
1240 if piv != c {
1241 for k in 0..n {
1242 m.swap(c * n + k, piv * n + k);
1243 }
1244 r.swap(c, piv);
1245 }
1246 for i in c + 1..n {
1247 let f = m[i * n + c] / m[c * n + c];
1248 for k in c..n {
1249 m[i * n + k] -= f * m[c * n + k];
1250 }
1251 r[i] -= f * r[c];
1252 }
1253 }
1254 let mut x = vec![0.0; n];
1255 for c in (0..n).rev() {
1256 let s: f64 = (c + 1..n).map(|k| m[c * n + k] * x[k]).sum();
1257 x[c] = (r[c] - s) / m[c * n + c];
1258 }
1259 x.iter().all(|v| v.is_finite()).then_some(x)
1260}
1261
1262fn invert(m: Vec<f64>, n: usize) -> Option<Vec<f64>> {
1265 let mut inv = vec![0.0; n * n];
1266 for c in 0..n {
1267 let mut e = vec![0.0; n];
1268 e[c] = 1.0;
1269 let x = solve(m.clone(), e, n)?;
1270 for r in 0..n {
1271 inv[r * n + c] = x[r];
1272 }
1273 }
1274 Some(inv)
1275}
1276
1277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1279pub struct PsfReport {
1280 pub profile: RadialProfile,
1282 pub fits: Vec<PsfFit>,
1284}
1285
1286impl PsfReport {
1287 pub fn profile_csv(&self) -> String {
1292 let p = &self.profile;
1293 let mut s = String::from(
1294 "r_lo_m,r_hi_m,r_center_m,area_m2,energy_ev,std_err_ev,density_ev_per_m2,density_std_err_ev_per_m2",
1295 );
1296 for f in &self.fits {
1297 s.push_str(&format!(",model_{}_ev", model_tag(f.model)));
1298 }
1299 s.push('\n');
1300 let d = p.density_ev_per_m2();
1301 let de = p.density_std_err_ev_per_m2();
1302 for i in 0..p.len() {
1303 s.push_str(&format!(
1304 "{},{},{},{},{},{},{},{}",
1305 p.edges_m[i],
1306 p.edges_m[i + 1],
1307 p.center_m(i),
1308 p.area_m2(i),
1309 p.energy_ev[i],
1310 p.std_err_ev[i],
1311 d[i],
1312 de[i]
1313 ));
1314 for f in &self.fits {
1315 s.push_str(&format!(",{}", f.residuals[i].model_ev));
1316 }
1317 s.push('\n');
1318 }
1319 s
1320 }
1321
1322 pub fn parameters_csv(&self) -> String {
1328 let mut s = String::from("model,parameter,value,std_error\n");
1329 for f in &self.fits {
1330 let tag = model_tag(f.model);
1331 for ((n, v), e) in f.parameter_names.iter().zip(&f.values).zip(&f.std_errors) {
1332 s.push_str(&format!("{tag},{n},{v},{e}\n"));
1333 }
1334 s.push_str(&format!("{tag},chi2,{},\n", f.chi2));
1335 s.push_str(&format!("{tag},dof,{},\n", f.dof));
1336 s.push_str(&format!("{tag},reduced_chi2,{},\n", f.reduced_chi2));
1337 s.push_str(&format!("{tag},converged,{},\n", u8::from(f.converged)));
1338 s.push_str(&format!("{tag},iterations,{},\n", f.iterations));
1339 }
1340 s
1341 }
1342}
1343
1344fn model_tag(m: PsfModel) -> &'static str {
1345 match m {
1346 PsfModel::DoubleGaussian => "double_gaussian",
1347 PsfModel::TripleGaussian => "triple_gaussian",
1348 }
1349}
1350
1351#[cfg(test)]
1352mod tests {
1353 use super::*;
1354
1355 #[test]
1356 fn log_binning_edges_and_locate_agree() {
1357 let b = LogRadialBinning::new(1e-9, 1e-5, 40).unwrap();
1358 let e = b.edges();
1359 assert_eq!(e.len(), 42);
1360 assert_eq!(e[0], 0.0);
1361 assert_eq!(e[1], 1e-9);
1362 assert_eq!(e[41], 1e-5);
1363 assert!(e.windows(2).all(|w| w[1] > w[0]));
1364 assert_eq!(b.locate(0.0), Some(0));
1365 assert_eq!(b.locate(0.5e-9), Some(0));
1366 assert_eq!(b.locate(1e-5), None);
1367 assert_eq!(b.locate(f64::NAN), None);
1368 for i in 1..=40 {
1369 let mid = (e[i] * e[i + 1]).sqrt();
1370 assert_eq!(b.locate(mid), Some(i), "bin {i}");
1371 }
1372 assert!(LogRadialBinning::new(0.0, 1.0, 3).is_err());
1373 assert!(LogRadialBinning::new(1.0, 1.0, 3).is_err());
1374 assert!(LogRadialBinning::new(1.0, 2.0, 0).is_err());
1375 assert!(PsfConfig::new(b, 1.0, 1.0).is_err());
1376 assert!(PsfConfig::new(b, 0.0, f64::INFINITY).is_err());
1377 }
1378
1379 #[test]
1380 fn annulus_integral_matches_quadrature() {
1381 let p = GaussianPsf::triple(7.0, 3e-9, 2e-7, 0.7, 4e-6, 0.2).unwrap();
1382 for (a, b) in [(0.0, 1e-9), (2e-9, 9e-9), (1e-7, 5e-7), (3e-6, 1e-5)] {
1383 let n = 20_000;
1385 let h = (b - a) / n as f64;
1386 let f = |r: f64| p.density_ev_per_m2(r) * 2.0 * PI * r;
1387 let mut s = f(a) + f(b);
1388 for i in 1..n {
1389 s += f(a + i as f64 * h) * if i % 2 == 1 { 4.0 } else { 2.0 };
1390 }
1391 let q = s * h / 3.0;
1392 let c = p.annulus_energy_ev(a, b);
1393 assert!(
1394 (q - c).abs() <= 1e-9 * c.abs().max(1e-300),
1395 "[{a}, {b}): {q} vs {c}"
1396 );
1397 }
1398 assert!((p.annulus_energy_ev(0.0, 1.0) - 7.0).abs() < 1e-12);
1400 let d = GaussianPsf::double(1.0, 1e-9, 1e-7, 0.5).unwrap();
1401 assert!((d.annulus_energy_ev(0.0, 1.0) - 1.0).abs() < 1e-15);
1402 let f0 = (1.0 / 1e-18 + 0.5 / 1e-14) / (PI * 1.5);
1404 assert!((d.normalized_per_m2(0.0) - f0).abs() < 1e-12 * f0);
1405 }
1406
1407 #[test]
1408 fn analytic_jacobian_matches_finite_differences() {
1409 for model in [PsfModel::DoubleGaussian, PsfModel::TripleGaussian] {
1410 let p: Vec<f64> = match model {
1411 PsfModel::DoubleGaussian => vec![100.0, 5e-9, 3e-7, 0.6],
1412 PsfModel::TripleGaussian => vec![100.0, 5e-9, 3e-7, 0.6, 4e-6, 0.3],
1413 };
1414 let b = LogRadialBinning::new(1e-10, 1e-4, 30).unwrap();
1415 let e = b.edges();
1416 let data = FitData {
1417 a: e[..e.len() - 1].to_vec(),
1418 b: e[1..].to_vec(),
1419 y: vec![0.0; e.len() - 1],
1421 sigma: vec![1.0; e.len() - 1],
1422 };
1423 let np = model.parameters();
1424 let (r0, jac) = data.residuals_and_jacobian(model, &p);
1425 for j in 0..np {
1426 let col_max = (0..r0.len()).fold(0.0f64, |m, i| m.max(jac[i * np + j].abs()));
1427 let h = 1e-6 * p[j];
1428 let mut pp = p.clone();
1429 pp[j] += h;
1430 let mut pm = p.clone();
1431 pm[j] -= h;
1432 let (rp, _) = data.residuals_and_jacobian(model, &pp);
1433 let (rm, _) = data.residuals_and_jacobian(model, &pm);
1434 for i in 0..r0.len() {
1435 let fd = -(rp[i] - rm[i]) / (2.0 * h);
1436 let an = jac[i * np + j];
1437 assert!(
1438 (fd - an).abs() <= 1e-6 * an.abs() + 1e-8 * col_max,
1439 "{model:?} p{j} bin {i}: {fd} vs {an}"
1440 );
1441 }
1442 }
1443 }
1444 }
1445
1446 #[test]
1447 fn accumulator_errors_follow_per_history_totals() {
1448 let b = LogRadialBinning::new(1.0, 100.0, 2).unwrap();
1449 let cfg = PsfConfig::new(b, 0.0, 1.0).unwrap();
1450 let mut acc = RadialAccumulator::new(cfg).unwrap();
1451 let hist: [&[(f64, f64)]; 3] = [
1453 &[(0.5, 2.0), (0.2, 1.0), (5.0, 3.0)],
1454 &[(5.0, 1.0)],
1455 &[(50.0, 4.0), (500.0, 1.0)],
1456 ];
1457 for h in hist {
1458 for &(r, e) in h {
1459 acc.deposit([0.5, r, 0.0], e);
1460 }
1461 acc.deposit([2.0, 0.0, 0.0], 100.0);
1463 acc.end_history();
1464 }
1465 let p = acc.profile();
1466 assert_eq!(p.histories, 3);
1467 assert_eq!(p.energy_ev, vec![3.0, 4.0, 4.0]);
1468 assert_eq!(p.beyond_ev, 1.0);
1469 assert_eq!(p.total_ev, 12.0);
1470 let se = |xs: [f64; 3]| {
1472 let m = xs.iter().sum::<f64>() / 3.0;
1473 let v = xs.iter().map(|x| (x - m).powi(2)).sum::<f64>() / 2.0;
1474 (3.0 * v).sqrt()
1475 };
1476 assert!((p.std_err_ev[0] - se([3.0, 0.0, 0.0])).abs() < 1e-12);
1477 assert!((p.std_err_ev[1] - se([3.0, 1.0, 0.0])).abs() < 1e-12);
1478 assert!((p.total_std_err_ev - se([6.0, 1.0, 5.0])).abs() < 1e-12);
1479 p.validate().unwrap();
1480 let mut a = RadialAccumulator::new(cfg).unwrap();
1482 let mut c = RadialAccumulator::new(cfg).unwrap();
1483 for (k, h) in hist.iter().enumerate() {
1484 let t = if k == 0 { &mut a } else { &mut c };
1485 for &(r, e) in *h {
1486 t.deposit([0.5, r, 0.0], e);
1487 }
1488 t.end_history();
1489 }
1490 a.merge(&c);
1491 assert_eq!(a.profile(), acc.profile());
1492 }
1493
1494 #[test]
1495 fn solver_and_inverse() {
1496 let m = vec![4.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0];
1497 let x = solve(m.clone(), vec![1.0, 2.0, 3.0], 3).unwrap();
1498 let r: Vec<f64> = (0..3)
1499 .map(|i| (0..3).map(|k| m[i * 3 + k] * x[k]).sum())
1500 .collect();
1501 for (a, b) in r.iter().zip([1.0, 2.0, 3.0]) {
1502 assert!((a - b).abs() < 1e-12);
1503 }
1504 let inv = invert(m.clone(), 3).unwrap();
1505 for i in 0..3 {
1506 for j in 0..3 {
1507 let s: f64 = (0..3).map(|k| m[i * 3 + k] * inv[k * 3 + j]).sum();
1508 assert!((s - if i == j { 1.0 } else { 0.0 }).abs() < 1e-12);
1509 }
1510 }
1511 assert!(solve(vec![1.0, 2.0, 2.0, 4.0], vec![1.0, 1.0], 2).is_none());
1512 }
1513}