1use nereids_core::constants::{DIVISION_FLOOR, NEAR_ZERO_FLOOR};
44use std::fmt;
45use std::sync::Arc;
46
47const TOF_FACTOR: f64 = 72.298_254_398_292_8;
54
55#[derive(Debug, PartialEq)]
57pub enum ResolutionError {
58 UnsortedEnergies,
60 LengthMismatch { energies: usize, data: usize },
62}
63
64impl fmt::Display for ResolutionError {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 match self {
67 Self::UnsortedEnergies => write!(
68 f,
69 "energy grid must be sorted in non-descending order for binary search"
70 ),
71 Self::LengthMismatch { energies, data } => write!(
72 f,
73 "energy grid length ({}) must match data length ({})",
74 energies, data
75 ),
76 }
77 }
78}
79
80impl std::error::Error for ResolutionError {}
81
82#[derive(Debug, PartialEq)]
84pub enum ResolutionParamsError {
85 InvalidFlightPath(f64),
87 InvalidDeltaT(f64),
89 InvalidDeltaL(f64),
91 InvalidDeltaE(f64),
93}
94
95impl fmt::Display for ResolutionParamsError {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 match self {
98 Self::InvalidFlightPath(v) => {
99 write!(f, "flight_path_m must be positive and finite, got {v}")
100 }
101 Self::InvalidDeltaT(v) => {
102 write!(f, "delta_t_us must be non-negative and finite, got {v}")
103 }
104 Self::InvalidDeltaL(v) => {
105 write!(f, "delta_l_m must be non-negative and finite, got {v}")
106 }
107 Self::InvalidDeltaE(v) => {
108 write!(f, "delta_e_us must be non-negative and finite, got {v}")
109 }
110 }
111 }
112}
113
114impl std::error::Error for ResolutionParamsError {}
115
116#[derive(Debug, Clone, Copy)]
118pub struct ResolutionParams {
119 flight_path_m: f64,
121 delta_t_us: f64,
124 delta_l_m: f64,
126 delta_e_us: f64,
134}
135
136impl ResolutionParams {
137 pub fn new(
156 flight_path_m: f64,
157 delta_t_us: f64,
158 delta_l_m: f64,
159 delta_e_us: f64,
160 ) -> Result<Self, ResolutionParamsError> {
161 if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
162 return Err(ResolutionParamsError::InvalidFlightPath(flight_path_m));
163 }
164 if !delta_t_us.is_finite() || delta_t_us < 0.0 {
165 return Err(ResolutionParamsError::InvalidDeltaT(delta_t_us));
166 }
167 if !delta_l_m.is_finite() || delta_l_m < 0.0 {
168 return Err(ResolutionParamsError::InvalidDeltaL(delta_l_m));
169 }
170 if !delta_e_us.is_finite() || delta_e_us < 0.0 {
171 return Err(ResolutionParamsError::InvalidDeltaE(delta_e_us));
172 }
173 Ok(Self {
174 flight_path_m,
175 delta_t_us,
176 delta_l_m,
177 delta_e_us,
178 })
179 }
180
181 #[must_use]
183 pub fn flight_path_m(&self) -> f64 {
184 self.flight_path_m
185 }
186
187 #[must_use]
192 pub fn delta_t_us(&self) -> f64 {
193 self.delta_t_us
194 }
195
196 #[must_use]
198 pub fn delta_l_m(&self) -> f64 {
199 self.delta_l_m
200 }
201
202 #[must_use]
204 pub fn delta_e_us(&self) -> f64 {
205 self.delta_e_us
206 }
207
208 #[must_use]
210 pub fn has_exponential_tail(&self) -> bool {
211 self.delta_e_us > NEAR_ZERO_FLOOR
212 }
213
214 #[must_use]
221 pub fn exp_width(&self, energy_ev: f64) -> f64 {
222 if energy_ev <= 0.0 || self.delta_e_us <= 0.0 {
223 return 0.0;
224 }
225 2.0 * self.delta_e_us * energy_ev.powf(1.5) / (TOF_FACTOR * self.flight_path_m)
226 }
227
228 #[must_use]
235 pub fn gaussian_width(&self, energy_ev: f64) -> f64 {
236 if energy_ev <= 0.0 || self.flight_path_m <= 0.0 {
237 return 0.0;
238 }
239
240 let timing =
242 2.0 * self.delta_t_us * energy_ev.powf(1.5) / (TOF_FACTOR * self.flight_path_m);
243
244 let path = 2.0 * self.delta_l_m * energy_ev / self.flight_path_m;
246
247 (timing * timing + path * path).sqrt()
248 }
249
250 #[must_use]
252 pub fn fwhm(&self, energy_ev: f64) -> f64 {
253 2.0 * (2.0_f64.ln()).sqrt() * self.gaussian_width(energy_ev)
254 }
255}
256
257pub fn resolution_broaden(
275 energies: &[f64],
276 cross_sections: &[f64],
277 params: &ResolutionParams,
278) -> Result<Vec<f64>, ResolutionError> {
279 validate_inputs(energies, cross_sections)?;
280 Ok(resolution_broaden_presorted(
281 energies,
282 cross_sections,
283 params,
284 ))
285}
286
287fn validate_inputs(energies: &[f64], data: &[f64]) -> Result<(), ResolutionError> {
289 if energies.len() != data.len() {
290 return Err(ResolutionError::LengthMismatch {
291 energies: energies.len(),
292 data: data.len(),
293 });
294 }
295 if !energies.windows(2).all(|w| w[0] <= w[1]) {
296 return Err(ResolutionError::UnsortedEnergies);
297 }
298 Ok(())
299}
300
301fn compute_xcoef_weights(energies: &[f64]) -> Vec<f64> {
317 let n = energies.len();
318 if n == 0 {
319 return vec![];
320 }
321 if n == 1 {
322 return vec![1.0];
323 }
324
325 let mut weights = vec![0.0f64; n];
344
345 let mut e = [0.0f64; 5];
350 e[3] = energies[0];
351 if n > 1 {
352 e[4] = energies[1];
353 }
354
355 for k in 0..n {
356 e[0] = e[1];
358 e[1] = e[2];
359 e[2] = e[3];
360 e[3] = e[4];
361 e[4] = if k + 2 < n { energies[k + 2] } else { 0.0 };
362
363 let v1 = e[1] - e[0];
364 let v2 = e[2] - e[1];
365 let v3 = e[3] - e[2];
366 let v4 = e[4] - e[3];
367
368 let mut a = [0.0f64; 6];
369
370 if k >= 2 {
371 a[0] = v1;
372 if v2.abs() > NEAR_ZERO_FLOOR {
374 a[4] = (v3 * v3 - v1 * v1) / v2;
375 }
376 }
377 if k >= 1 {
378 a[1] = 5.0 * v2;
379 if v3.abs() > NEAR_ZERO_FLOOR {
381 a[5] = -(v4 * v4 - v2 * v2) / v3;
382 }
383 }
384 if k != n - 1 {
385 a[2] = 5.0 * v3;
386 }
387 if k < n.saturating_sub(2) {
388 a[3] = v4;
389 }
390
391 if k == n.saturating_sub(2) {
393 a[5] = 0.0;
394 }
395 if k == n - 1 {
396 a[4] = 0.0;
397 a[5] = 0.0;
398 }
399
400 weights[k] = a.iter().sum::<f64>();
401 }
402
403 weights
404}
405
406fn erfc_from_exerfc(x: f64) -> f64 {
412 const SQRT_PI: f64 = 1.772_453_850_905_516;
413 if x >= 0.0 {
414 (-x * x).exp() * exerfc(x) / SQRT_PI
415 } else {
416 let xp = -x;
417 2.0 - (-xp * xp).exp() * exerfc(xp) / SQRT_PI
418 }
419}
420
421pub(crate) fn exerfc(x: f64) -> f64 {
430 const SQRT_PI: f64 = 1.772_453_850_905_516;
431 const TWO_SQRT_PI: f64 = 3.544_907_701_811_032;
432 const XMAX: f64 = 5.01;
433 const A1: f64 = 8.584_076_57e-1;
435 const A2: f64 = 3.078_181_93e-1;
436 const A3: f64 = 6.383_238_91e-2;
437 const A4: f64 = 1.824_050_75e-4;
438 const A5: f64 = 6.509_742_65e-1;
439 const A6: f64 = 2.294_848_19e-1;
440 const A7: f64 = 3.403_018_23e-2;
441
442 if x < 0.0 {
443 let xp = -x;
444 if xp > XMAX {
445 TWO_SQRT_PI - asympt(xp)
446 } else {
447 let a =
448 (A1 + xp * (A2 + xp * (A3 - xp * A4))) / (1.0 + xp * (A5 + xp * (A6 + xp * A7)));
449 let b = SQRT_PI + xp * (2.0 - a);
450 let a_rat = b / (xp * b + 1.0);
451 TWO_SQRT_PI * (x * x).exp() - a_rat
452 }
453 } else if x > XMAX {
454 asympt(x)
455 } else if x > 0.0 {
456 let a = (A1 + x * (A2 + x * (A3 - x * A4))) / (1.0 + x * (A5 + x * (A6 + x * A7)));
457 let b = SQRT_PI + x * (2.0 - a);
458 b / (x * b + 1.0)
459 } else {
460 SQRT_PI
461 }
462}
463
464fn asympt(x: f64) -> f64 {
469 if x == 0.0 {
470 return 0.0;
471 }
472 let e = 1.0 / x;
473 if e == 0.0 {
474 return 0.0;
475 }
476 let b = 1.0 / (x * x);
477 let mut a = 1.0;
478 let mut c = b * 0.5;
479 for n in 1..=40 {
480 a -= c;
481 c *= -(n as f64 + 0.5) * b;
482 if (a - c) == a || (c / a).abs() < 1e-8 {
483 break;
484 }
485 }
486 a * e
487}
488
489fn gauss_exp_kernel(a: f64, b: f64) -> f64 {
499 if b >= 0.0 {
500 let exp_neg_a2 = (-a * a).exp();
501 if exp_neg_a2 == 0.0 {
502 return 0.0;
503 }
504 exp_neg_a2 * exerfc(b)
505 } else {
506 xxerfc(b, a)
511 }
512}
513
514fn xxerfc(xx: f64, xxx: f64) -> f64 {
524 const SQRT_PI: f64 = 1.772_453_850_905_516;
525 const XMAX: f64 = 5.01;
526 const A1: f64 = 8.584_076_57e-1;
527 const A2: f64 = 3.078_181_93e-1;
528 const A3: f64 = 6.383_238_91e-2;
529 const A4: f64 = 1.824_050_75e-4;
530 const A5: f64 = 6.509_742_65e-1;
531 const A6: f64 = 2.294_848_19e-1;
532 const A7: f64 = 3.403_018_23e-2;
533
534 let x = -xx; if x > XMAX {
540 return (-xxx * xxx).exp() * asympt(x);
541 }
542
543 let a_rat = (A1 + x * (A2 + x * (A3 - x * A4))) / (1.0 + x * (A5 + x * (A6 + x * A7)));
544 let b_int = SQRT_PI + x * (2.0 - a_rat);
545 let a_final = b_int / (x * b_int + 1.0);
546 let exp_term = (-xxx * xxx + x * x).exp();
548 SQRT_PI * 2.0 * exp_term - a_final * (-xxx * xxx).exp()
549}
550
551fn shftge(c: f64, widgau: f64) -> f64 {
565 const ONE_OVER_SQRT_PI: f64 = 0.564_189_583_547_756_3;
566 const SMALL: f64 = 0.01;
567
568 let ax = c;
569 let bx = widgau;
570
571 let mut x0 = if ax > ONE_OVER_SQRT_PI { ax } else { 0.0 };
573
574 let f0_initial = ax * exerfc(x0) - 1.0;
575 let mut f0 = f0_initial;
576 let fff = f0;
577
578 for _iter in 0..100 {
579 let f = ax * exerfc(x0) - 1.0;
580 let xma = x0 - ax;
581 let q = 1.0 - 2.0 * x0 * xma;
582 let delx = if q.abs() < NEAR_ZERO_FLOOR {
583 break;
585 } else if xma * xma - q * f > 0.0 {
586 let disc = (xma * xma - q * f).sqrt();
587 if xma > 0.0 {
588 (-xma + disc) / q
589 } else {
590 (-xma - disc) / q
591 }
592 } else {
593 if xma.abs() < NEAR_ZERO_FLOOR {
594 break;
595 }
596 -f * 0.5 / xma
597 };
598 let x1 = x0 + delx;
599 let shftg = (ax - x1) * bx;
600 if (x1 - x0).abs() / x1.abs().max(1.0) < SMALL
601 && fff.abs() > NEAR_ZERO_FLOOR
602 && (f - f0).abs() / fff.abs() < SMALL
603 {
604 return shftg;
605 }
606 f0 = f;
607 x0 = x1;
608 }
609
610 (ax - x0) * bx
611}
612
613const EXP_TAIL_NEGLIGIBLE_C: f64 = 2.5;
621
622pub(crate) fn resolution_broaden_presorted(
633 energies: &[f64],
634 cross_sections: &[f64],
635 params: &ResolutionParams,
636) -> Vec<f64> {
637 let n = energies.len();
638 if n == 0 {
639 return vec![];
640 }
641
642 let xcoef = if params.has_exponential_tail() {
646 compute_xcoef_weights(energies)
647 } else {
648 vec![]
649 };
650 let n_sigma = 5.0; let mut broadened = vec![0.0f64; n];
652
653 for i in 0..n {
654 let e = energies[i];
655 let widgau = params.gaussian_width(e);
656
657 if widgau < NEAR_ZERO_FLOOR {
658 broadened[i] = cross_sections[i];
659 continue;
660 }
661
662 let widexp = params.exp_width(e);
665 let use_combined =
666 widexp > NEAR_ZERO_FLOOR && widgau / (2.0 * widexp) <= EXP_TAIL_NEGLIGIBLE_C;
667
668 let (e_low, e_high) = if use_combined {
670 let wlow = n_sigma * widgau;
672 let rwid = widgau / widexp;
673 let wup = if rwid <= 1.0 {
674 6.25 * widexp
675 } else if rwid <= 2.0 {
676 n_sigma * (3.0 - rwid) * widgau
677 } else {
678 n_sigma * widgau
679 };
680 (e - wlow, e + wup)
681 } else {
682 (e - n_sigma * widgau, e + n_sigma * widgau)
683 };
684
685 let j_lo = energies.partition_point(|&ej| ej < e_low);
686 let j_hi = energies.partition_point(|&ej| ej <= e_high);
687
688 if j_hi.saturating_sub(j_lo) <= 1 {
689 broadened[i] = cross_sections[i];
690 continue;
691 }
692
693 let mut sum = 0.0;
694 let mut norm = 0.0;
695
696 if use_combined {
697 let c = widgau * 0.5 / widexp;
701 let est = shftge(c, widgau);
702 let y = c * widgau + e - est;
703
704 for j in j_lo..j_hi {
705 let ee = energies[j];
706 let a = (e - est - ee) / widgau;
707 let b = (y - ee) / widgau;
708 let z = gauss_exp_kernel(a, b);
709 let wt = xcoef[j] * z;
710 sum += wt * cross_sections[j];
711 norm += wt;
712 }
713 } else {
714 const TWO_OVER_SQRT_PI: f64 = std::f64::consts::FRAC_2_SQRT_PI;
733 let inv_w = 1.0 / widgau;
734 for j in j_lo..j_hi.saturating_sub(1) {
735 let e_j = energies[j];
736 let e_j1 = energies[j + 1];
737 let h = e_j1 - e_j;
738 if h < NEAR_ZERO_FLOOR {
739 continue;
740 }
741
742 let a_j = (e_j - e) * inv_w;
743 let a_j1 = (e_j1 - e) * inv_w;
744
745 let erfc_aj = erfc_from_exerfc(a_j);
747 let erfc_aj1 = erfc_from_exerfc(a_j1);
748 let i0 = erfc_aj - erfc_aj1;
749
750 if i0 < NEAR_ZERO_FLOOR {
751 continue;
752 }
753
754 let i1 = ((-a_j * a_j).exp() - (-a_j1 * a_j1).exp()) * 0.5;
756
757 let slope = (cross_sections[j + 1] - cross_sections[j]) / h;
758
759 sum += cross_sections[j] * i0 + slope * widgau * (TWO_OVER_SQRT_PI * i1 - a_j * i0);
761 norm += i0;
762 }
763 }
764
765 if norm > DIVISION_FLOOR {
766 broadened[i] = sum / norm;
767 } else {
768 broadened[i] = cross_sections[i];
769 }
770 }
771
772 broadened
773}
774
775pub fn resolution_broaden_transmission(
794 energies: &[f64],
795 transmission: &[f64],
796 params: &ResolutionParams,
797) -> Result<Vec<f64>, ResolutionError> {
798 resolution_broaden(energies, transmission, params)
801}
802
803#[derive(Debug, Clone)]
822pub struct TabulatedResolution {
823 ref_energies: Vec<f64>,
825 kernels: Vec<(Vec<f64>, Vec<f64>)>,
828 flight_path_m: f64,
830}
831
832impl TabulatedResolution {
833 pub fn ref_energies(&self) -> &[f64] {
835 &self.ref_energies
836 }
837
838 pub fn kernels(&self) -> &[(Vec<f64>, Vec<f64>)] {
841 &self.kernels
842 }
843
844 pub fn flight_path_m(&self) -> f64 {
846 self.flight_path_m
847 }
848}
849
850#[derive(Debug, Clone)]
855pub enum ResolutionFunction {
856 Gaussian(ResolutionParams),
858 Tabulated(Arc<TabulatedResolution>),
860}
861
862impl TabulatedResolution {
863 pub fn from_text(text: &str, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
869 let mut lines = text.lines();
870
871 let _header = lines
873 .next()
874 .ok_or(ResolutionParseError::InvalidFormat("Empty file".into()))?;
875 let _sep = lines.next().ok_or(ResolutionParseError::InvalidFormat(
876 "Missing separator".into(),
877 ))?;
878
879 let mut ref_energies = Vec::new();
880 let mut kernels = Vec::new();
881 let mut current_energy: Option<f64> = None;
882 let mut current_offsets: Vec<f64> = Vec::new();
883 let mut current_weights: Vec<f64> = Vec::new();
884
885 for line in lines {
886 let trimmed = line.trim();
887 if trimmed.is_empty() {
888 if let Some(e) = current_energy.take() {
890 ref_energies.push(e);
891 kernels.push((
892 std::mem::take(&mut current_offsets),
893 std::mem::take(&mut current_weights),
894 ));
895 }
896 continue;
897 }
898
899 let parts: Vec<&str> = trimmed.split_whitespace().collect();
900 if parts.len() != 2 {
901 if current_energy.is_some() {
902 return Err(ResolutionParseError::InvalidFormat(format!(
903 "Expected 2 columns inside energy block, got {}: '{}'",
904 parts.len(),
905 trimmed
906 )));
907 }
908 continue;
910 }
911
912 let x: f64 = parts[0].parse().map_err(|_| {
913 ResolutionParseError::InvalidFormat(format!("Cannot parse float: '{}'", parts[0]))
914 })?;
915 let y: f64 = parts[1].parse().map_err(|_| {
916 ResolutionParseError::InvalidFormat(format!("Cannot parse float: '{}'", parts[1]))
917 })?;
918
919 if current_energy.is_none() {
920 current_energy = Some(x);
922 } else {
923 current_offsets.push(x);
924 current_weights.push(y);
925 }
926 }
927
928 if let Some(e) = current_energy.take() {
930 ref_energies.push(e);
931 kernels.push((current_offsets, current_weights));
932 }
933
934 if ref_energies.is_empty() {
935 return Err(ResolutionParseError::InvalidFormat(
936 "No energy blocks found".into(),
937 ));
938 }
939
940 for i in 1..ref_energies.len() {
942 if ref_energies[i] <= ref_energies[i - 1] {
943 return Err(ResolutionParseError::InvalidFormat(format!(
944 "Reference energies must be strictly ascending, but E[{}]={} <= E[{}]={}",
945 i,
946 ref_energies[i],
947 i - 1,
948 ref_energies[i - 1],
949 )));
950 }
951 }
952
953 Ok(TabulatedResolution {
954 ref_energies,
955 kernels,
956 flight_path_m,
957 })
958 }
959
960 pub fn from_file(path: &str, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
962 let text = std::fs::read_to_string(path)
963 .map_err(|e| ResolutionParseError::IoError(format!("Cannot read '{}': {}", path, e)))?;
964 Self::from_text(&text, flight_path_m)
965 }
966
967 pub fn broaden(&self, energies: &[f64], spectrum: &[f64]) -> Result<Vec<f64>, ResolutionError> {
979 validate_inputs(energies, spectrum)?;
980 Ok(self.broaden_presorted(energies, spectrum))
981 }
982
983 pub(crate) fn broaden_presorted(&self, energies: &[f64], spectrum: &[f64]) -> Vec<f64> {
986 let n = energies.len();
987 if n == 0 {
988 return vec![];
989 }
990
991 let mut result = vec![0.0f64; n];
992
993 for i in 0..n {
994 let e = energies[i];
995 if e <= 0.0 {
996 result[i] = spectrum[i];
997 continue;
998 }
999
1000 let tof_center = TOF_FACTOR * self.flight_path_m / e.sqrt();
1002
1003 let (offsets, weights) = self.interpolated_kernel(e);
1005
1006 let mut sum = 0.0;
1009 let mut norm = 0.0;
1010
1011 for k in 0..offsets.len() {
1012 let dt = offsets[k];
1013 let w = weights[k];
1014 if w <= 0.0 {
1015 continue;
1016 }
1017
1018 let tof_prime = tof_center + dt;
1019 if tof_prime <= 0.0 {
1020 continue;
1021 }
1022
1023 let e_prime = (TOF_FACTOR * self.flight_path_m / tof_prime).powi(2);
1025
1026 let s = match interp_spectrum(energies, spectrum, e_prime) {
1028 Some(v) => v,
1029 None => continue,
1030 };
1031
1032 let dt_width = if k > 0 && k < offsets.len() - 1 {
1034 (offsets[k + 1] - offsets[k - 1]) * 0.5
1035 } else if k == 0 && offsets.len() > 1 {
1036 offsets[1] - offsets[0]
1037 } else if k == offsets.len() - 1 && offsets.len() > 1 {
1038 offsets[k] - offsets[k - 1]
1039 } else {
1040 1.0
1041 };
1042
1043 let weight = w * dt_width.abs();
1044 sum += weight * s;
1045 norm += weight;
1046 }
1047
1048 result[i] = if norm > DIVISION_FLOOR {
1049 sum / norm
1050 } else {
1051 spectrum[i]
1052 };
1053 }
1054
1055 result
1056 }
1057
1058 fn interpolated_kernel(&self, energy: f64) -> (Vec<f64>, Vec<f64>) {
1064 debug_assert!(
1065 self.ref_energies.windows(2).all(|w| w[0] < w[1]),
1066 "ref_energies must be strictly ascending (invariant broken)"
1067 );
1068 let n_ref = self.ref_energies.len();
1069
1070 if energy <= self.ref_energies[0] || n_ref == 1 {
1072 return self.kernels[0].clone();
1073 }
1074 if energy >= self.ref_energies[n_ref - 1] {
1075 return self.kernels[n_ref - 1].clone();
1076 }
1077
1078 let pos = self.ref_energies.partition_point(|&e| e < energy);
1080 let idx = if pos == 0 {
1081 0
1082 } else {
1083 (pos - 1).min(n_ref - 2)
1084 };
1085
1086 let e_lo = self.ref_energies[idx];
1087 let e_hi = self.ref_energies[idx + 1];
1088
1089 let frac = (energy.ln() - e_lo.ln()) / (e_hi.ln() - e_lo.ln());
1091
1092 let (off_lo, w_lo) = &self.kernels[idx];
1093 let (off_hi, w_hi) = &self.kernels[idx + 1];
1094
1095 if off_lo.len() == off_hi.len() {
1097 let offsets: Vec<f64> = off_lo
1098 .iter()
1099 .zip(off_hi.iter())
1100 .map(|(&a, &b)| a + frac * (b - a))
1101 .collect();
1102 let weights: Vec<f64> = w_lo
1103 .iter()
1104 .zip(w_hi.iter())
1105 .map(|(&a, &b)| a + frac * (b - a))
1106 .collect();
1107 (offsets, weights)
1108 } else {
1109 if frac < 0.5 {
1111 self.kernels[idx].clone()
1112 } else {
1113 self.kernels[idx + 1].clone()
1114 }
1115 }
1116 }
1117}
1118
1119fn interp_spectrum(energies: &[f64], spectrum: &[f64], e: f64) -> Option<f64> {
1124 let n = energies.len();
1125 if n == 0 {
1126 return None;
1127 }
1128 if e < energies[0] || e > energies[n - 1] {
1129 return None;
1130 }
1131
1132 let mut lo = 0;
1134 let mut hi = n - 1;
1135 while hi - lo > 1 {
1136 let mid = (lo + hi) / 2;
1137 if energies[mid] <= e {
1138 lo = mid;
1139 } else {
1140 hi = mid;
1141 }
1142 }
1143
1144 let span = energies[hi] - energies[lo];
1148 if span.abs() < NEAR_ZERO_FLOOR {
1149 return Some(spectrum[lo]);
1150 }
1151 let frac = (e - energies[lo]) / span;
1152 Some(spectrum[lo] + frac * (spectrum[hi] - spectrum[lo]))
1153}
1154
1155pub fn apply_resolution(
1161 energies: &[f64],
1162 spectrum: &[f64],
1163 resolution: &ResolutionFunction,
1164) -> Result<Vec<f64>, ResolutionError> {
1165 match resolution {
1166 ResolutionFunction::Gaussian(params) => resolution_broaden(energies, spectrum, params),
1167 ResolutionFunction::Tabulated(tab) => tab.broaden(energies, spectrum),
1168 }
1169}
1170
1171pub(crate) fn apply_resolution_presorted(
1177 energies: &[f64],
1178 spectrum: &[f64],
1179 resolution: &ResolutionFunction,
1180) -> Vec<f64> {
1181 match resolution {
1182 ResolutionFunction::Gaussian(params) => {
1183 resolution_broaden_presorted(energies, spectrum, params)
1184 }
1185 ResolutionFunction::Tabulated(tab) => tab.broaden_presorted(energies, spectrum),
1186 }
1187}
1188
1189#[derive(Debug)]
1191pub enum ResolutionParseError {
1192 InvalidFormat(String),
1193 IoError(String),
1194}
1195
1196impl fmt::Display for ResolutionParseError {
1197 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1198 match self {
1199 Self::InvalidFormat(msg) => write!(f, "Invalid resolution file format: {}", msg),
1200 Self::IoError(msg) => write!(f, "I/O error: {}", msg),
1201 }
1202 }
1203}
1204
1205impl std::error::Error for ResolutionParseError {}
1206
1207#[cfg(test)]
1208mod tests {
1209 use super::*;
1210 use nereids_core::constants;
1211
1212 #[test]
1213 fn test_tof_factor_consistency() {
1214 let e = 10.0; let l = 25.0; let tof_constants = constants::energy_to_tof(e, l);
1218 let tof_ours = TOF_FACTOR * l / e.sqrt();
1219 let rel_diff = (tof_constants - tof_ours).abs() / tof_constants;
1220 assert!(
1221 rel_diff < 1e-10,
1222 "TOF mismatch: constants={}, ours={}, diff={:.4}%",
1223 tof_constants,
1224 tof_ours,
1225 rel_diff * 100.0
1226 );
1227 }
1228
1229 #[test]
1230 fn test_resolution_width_scaling() {
1231 let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
1232
1233 let w1 = params.gaussian_width(1.0);
1235 let w10 = params.gaussian_width(10.0);
1236 let w100 = params.gaussian_width(100.0);
1237
1238 assert!(w10 > w1, "Width should increase with energy");
1239 assert!(w100 > w10, "Width should increase with energy");
1240
1241 let ratio = w10 / w1;
1245 assert!(
1246 ratio > 5.0 && ratio < 40.0,
1247 "Width ratio = {}, expected between 10 and 31.6",
1248 ratio
1249 );
1250 }
1251
1252 #[test]
1253 fn test_zero_width_passthrough() {
1254 let energies = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1256 let xs = vec![10.0, 20.0, 30.0, 20.0, 10.0];
1257 let params = ResolutionParams::new(25.0, 0.0, 0.0, 0.0).unwrap();
1258 let broadened = resolution_broaden(&energies, &xs, ¶ms).unwrap();
1259 assert_eq!(broadened, xs);
1260 }
1261
1262 #[test]
1263 fn test_broadening_reduces_peak() {
1264 let n = 1001;
1266 let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.01).collect();
1267 let center = 10.0;
1268 let gamma: f64 = 0.1; let xs: Vec<f64> = energies
1270 .iter()
1271 .map(|&e| {
1272 let de = e - center;
1273 1000.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
1274 })
1275 .collect();
1276
1277 let params = ResolutionParams::new(25.0, 5.0, 0.01, 0.0).unwrap();
1278 let broadened = resolution_broaden(&energies, &xs, ¶ms).unwrap();
1279
1280 let orig_peak = xs.iter().cloned().fold(0.0_f64, f64::max);
1281 let broad_peak = broadened.iter().cloned().fold(0.0_f64, f64::max);
1282
1283 assert!(
1284 broad_peak < orig_peak,
1285 "Broadened peak ({}) should be < original ({})",
1286 broad_peak,
1287 orig_peak
1288 );
1289 assert!(
1290 broad_peak > 1.0,
1291 "Broadened peak ({}) should still be substantial",
1292 broad_peak
1293 );
1294 }
1295
1296 #[test]
1297 fn test_broadening_conserves_area() {
1298 let n = 2001;
1301 let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.005).collect();
1302 let center = 10.0;
1303 let gamma: f64 = 0.5;
1304 let xs: Vec<f64> = energies
1305 .iter()
1306 .map(|&e| {
1307 let de = e - center;
1308 1000.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
1309 })
1310 .collect();
1311
1312 let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
1313 let broadened = resolution_broaden(&energies, &xs, ¶ms).unwrap();
1314
1315 let area_orig: f64 = (0..n - 1)
1317 .map(|i| 0.5 * (xs[i] + xs[i + 1]) * (energies[i + 1] - energies[i]))
1318 .sum();
1319 let area_broad: f64 = (0..n - 1)
1320 .map(|i| 0.5 * (broadened[i] + broadened[i + 1]) * (energies[i + 1] - energies[i]))
1321 .sum();
1322
1323 let rel_diff = (area_orig - area_broad).abs() / area_orig;
1324 assert!(
1325 rel_diff < 0.02,
1326 "Area not conserved: orig={:.2}, broad={:.2}, rel_diff={:.4}",
1327 area_orig,
1328 area_broad,
1329 rel_diff
1330 );
1331 }
1332
1333 #[test]
1334 fn test_gaussian_broadening_analytical() {
1335 let n = 2001;
1344 let center = 10.0;
1345 let sigma_input = 0.5; let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.005).collect();
1347 let xs: Vec<f64> = energies
1348 .iter()
1349 .map(|&e| {
1350 let de = e - center;
1351 1000.0 * (-de * de / (2.0 * sigma_input * sigma_input)).exp()
1352 })
1353 .collect();
1354
1355 let w_kernel = 0.3; let params =
1359 ResolutionParams::new(25.0, 0.0, w_kernel * 25.0 / (2.0 * center), 0.0).unwrap();
1360
1361 let w_at_center = params.gaussian_width(center);
1363 assert!(
1364 (w_at_center - w_kernel).abs() / w_kernel < 0.01,
1365 "Kernel W at center: {}, expected {}",
1366 w_at_center,
1367 w_kernel
1368 );
1369
1370 let broadened = resolution_broaden(&energies, &xs, ¶ms).unwrap();
1371
1372 let sigma_kernel = w_kernel / 2.0_f64.sqrt();
1374 let sigma_expected = (sigma_input * sigma_input + sigma_kernel * sigma_kernel).sqrt();
1375 let fwhm_expected = 2.0 * (2.0_f64.ln() * 2.0).sqrt() * sigma_expected;
1376
1377 let peak_idx = broadened
1379 .iter()
1380 .enumerate()
1381 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
1382 .unwrap()
1383 .0;
1384 let peak_val = broadened[peak_idx];
1385 let half_max = peak_val / 2.0;
1386
1387 let mut left_hm = energies[0];
1388 for i in (0..peak_idx).rev() {
1389 if broadened[i] < half_max {
1390 let t = (half_max - broadened[i]) / (broadened[i + 1] - broadened[i]);
1391 left_hm = energies[i] + t * (energies[i + 1] - energies[i]);
1392 break;
1393 }
1394 }
1395 let mut right_hm = energies[n - 1];
1396 for i in peak_idx..n - 1 {
1397 if broadened[i + 1] < half_max {
1398 let t = (half_max - broadened[i]) / (broadened[i + 1] - broadened[i]);
1399 right_hm = energies[i] + t * (energies[i + 1] - energies[i]);
1400 break;
1401 }
1402 }
1403
1404 let fwhm_measured = right_hm - left_hm;
1405 let rel_err = (fwhm_measured - fwhm_expected).abs() / fwhm_expected;
1406
1407 assert!(
1408 rel_err < 0.05,
1409 "FWHM: measured={:.4}, expected={:.4}, rel_err={:.2}%",
1410 fwhm_measured,
1411 fwhm_expected,
1412 rel_err * 100.0
1413 );
1414 }
1415
1416 #[test]
1417 fn test_venus_typical_resolution() {
1418 let params = ResolutionParams::new(25.0, 10.0, 0.01, 0.0).unwrap();
1421
1422 let de_1 = params.gaussian_width(1.0);
1424 let de_over_e_1 = de_1 / 1.0;
1425 assert!(
1426 de_over_e_1 < 0.05,
1427 "ΔE/E at 1 eV = {:.4}, should be < 5%",
1428 de_over_e_1
1429 );
1430
1431 let de_100 = params.gaussian_width(100.0);
1433 let de_over_e_100 = de_100 / 100.0;
1434 assert!(
1435 de_over_e_100 > de_over_e_1,
1436 "Resolution should degrade at higher energies"
1437 );
1438 }
1439
1440 #[test]
1441 fn test_unsorted_energies_returns_error() {
1442 let energies = vec![1.0, 3.0, 2.0, 4.0]; let xs = vec![10.0, 30.0, 20.0, 40.0];
1444 let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
1445 let result = resolution_broaden(&energies, &xs, ¶ms);
1446 assert!(result.is_err());
1447 assert!(matches!(
1448 result.unwrap_err(),
1449 ResolutionError::UnsortedEnergies
1450 ));
1451 }
1452
1453 #[test]
1454 fn test_length_mismatch_returns_error() {
1455 let energies = vec![1.0, 2.0, 3.0];
1456 let xs = vec![10.0, 20.0]; let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
1458 let result = resolution_broaden(&energies, &xs, ¶ms);
1459 assert!(result.is_err());
1460 assert!(matches!(
1461 result.unwrap_err(),
1462 ResolutionError::LengthMismatch {
1463 energies: 3,
1464 data: 2
1465 }
1466 ));
1467 }
1468
1469 #[test]
1472 fn test_resolution_params_valid() {
1473 let p = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
1474 assert!((p.flight_path_m() - 25.0).abs() < 1e-15);
1475 assert!((p.delta_t_us() - 1.0).abs() < 1e-15);
1476 assert!((p.delta_l_m() - 0.01).abs() < 1e-15);
1477 }
1478
1479 #[test]
1480 fn test_resolution_params_rejects_zero_flight_path() {
1481 let err = ResolutionParams::new(0.0, 1.0, 0.01, 0.0).unwrap_err();
1482 assert_eq!(err, ResolutionParamsError::InvalidFlightPath(0.0));
1483 }
1484
1485 #[test]
1486 fn test_resolution_params_rejects_negative_flight_path() {
1487 let err = ResolutionParams::new(-1.0, 1.0, 0.01, 0.0).unwrap_err();
1488 assert_eq!(err, ResolutionParamsError::InvalidFlightPath(-1.0));
1489 }
1490
1491 #[test]
1492 fn test_resolution_params_rejects_nan_flight_path() {
1493 let err = ResolutionParams::new(f64::NAN, 1.0, 0.01, 0.0).unwrap_err();
1494 assert!(matches!(err, ResolutionParamsError::InvalidFlightPath(_)));
1495 }
1496
1497 #[test]
1498 fn test_resolution_params_rejects_negative_delta_t() {
1499 let err = ResolutionParams::new(25.0, -1.0, 0.01, 0.0).unwrap_err();
1500 assert_eq!(err, ResolutionParamsError::InvalidDeltaT(-1.0));
1501 }
1502
1503 #[test]
1504 fn test_resolution_params_rejects_nan_delta_t() {
1505 let err = ResolutionParams::new(25.0, f64::NAN, 0.01, 0.0).unwrap_err();
1506 assert!(matches!(err, ResolutionParamsError::InvalidDeltaT(_)));
1507 }
1508
1509 #[test]
1510 fn test_resolution_params_rejects_negative_delta_l() {
1511 let err = ResolutionParams::new(25.0, 1.0, -0.01, 0.0).unwrap_err();
1512 assert_eq!(err, ResolutionParamsError::InvalidDeltaL(-0.01));
1513 }
1514
1515 #[test]
1516 fn test_resolution_params_rejects_inf_delta_l() {
1517 let err = ResolutionParams::new(25.0, 1.0, f64::INFINITY, 0.0).unwrap_err();
1518 assert!(matches!(err, ResolutionParamsError::InvalidDeltaL(_)));
1519 }
1520
1521 #[test]
1522 fn test_resolution_params_rejects_negative_delta_e() {
1523 let err = ResolutionParams::new(25.0, 1.0, 0.01, -0.05).unwrap_err();
1524 assert_eq!(err, ResolutionParamsError::InvalidDeltaE(-0.05));
1525 }
1526
1527 #[test]
1528 fn test_resolution_params_rejects_nan_delta_e() {
1529 let err = ResolutionParams::new(25.0, 1.0, 0.01, f64::NAN).unwrap_err();
1530 assert!(matches!(err, ResolutionParamsError::InvalidDeltaE(_)));
1531 }
1532
1533 #[test]
1534 fn test_resolution_params_accepts_zero_delta_e() {
1535 let p = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
1536 assert!((p.delta_e_us() - 0.0).abs() < 1e-15);
1537 assert!(!p.has_exponential_tail());
1538 }
1539}