1use std::ops::RangeInclusive;
137use std::sync::Arc;
138
139use crate::resolution::{ResolutionParseError, TOF_FACTOR, TabulatedResolution};
140
141const LAMBDA_ANGSTROM_FACTOR: f64 = 0.285_993;
147
148const MIN_RATE: f64 = 1e-9;
150
151pub const DETECTOR_TAIL_DECAY_US: RangeInclusive<f64> = 1e-9..=1e9;
153
154const TRIM_REL: f64 = 1e-7;
156
157pub const DEFAULT_N_ENERGIES: usize = 64;
159
160pub const DEFAULT_N_TAU: usize = 600;
162
163const MIN_N_TAU: usize = 8;
170
171const TRI_MIN_SAMPLES_PER_SIDE: f64 = 3.0;
182
183const GAUSS_REACH_SIGMAS: f64 = 8.0;
184
185const FAST_REACH_E_FOLDS: f64 = 18.0;
188
189const SLOW_REACH_E_FOLDS: f64 = 16.0;
195
196const R_NEGLIGIBLE: f64 = 1e-9;
209
210const MAX_TAU_SAMPLES: usize = 8192;
225
226#[inline]
227fn h_over_cube_taylor(u: f64) -> f64 {
228 exponential_tail_series(u, 3)
229}
230
231#[inline]
232fn exponential_tail_series(u: f64, shift: u32) -> f64 {
233 let mut sum = 0.0;
234 let mut power = 0.5;
235 for n in 0..64_u32 {
236 let next = sum + power / f64::from(n + shift);
237 if next == sum {
238 break;
239 }
240 sum = next;
241 power *= -u / f64::from(n + 1);
242 }
243 sum
244}
245
246#[must_use]
255pub fn ic_pulse(alpha: f64, beta: f64, r: f64, tau: f64) -> f64 {
256 if !alpha.is_finite() || !beta.is_finite() || !r.is_finite() {
259 return f64::NAN;
260 }
261 if !tau.is_finite() || tau < 0.0 {
262 return 0.0;
263 }
264 let alpha = alpha.max(MIN_RATE);
265 let beta = beta.max(MIN_RATE);
266 let at = alpha * tau;
267 let fast = 0.5 * alpha * at * at * (-at).exp();
269 if r <= 0.0 {
270 return fast;
271 }
272 (1.0 - r) * fast + r * storage_density(alpha, beta, tau)
273}
274
275fn storage_density(alpha: f64, beta: f64, tau: f64) -> f64 {
276 let u = (alpha - beta) * tau;
278 let coeff = beta * alpha.powi(3) * tau.powi(3);
279 if u.abs() < 0.05 {
280 coeff * (-beta * tau).exp() * h_over_cube_taylor(u)
282 } else {
283 let bracket = (-beta * tau).exp() - (-alpha * tau).exp() * (1.0 + u + 0.5 * u * u);
286 coeff * bracket / (u * u * u)
287 }
288}
289
290fn ic_pulse_slope(alpha: f64, beta: f64, r: f64, tau: f64) -> f64 {
291 if !tau.is_finite() || tau < 0.0 {
292 return 0.0;
293 }
294 let alpha = alpha.max(MIN_RATE);
295 let beta = beta.max(MIN_RATE);
296 let at = alpha * tau;
297 let prompt = 0.5 * alpha * at * at * (-at).exp();
298 let prompt_slope = 0.5 * alpha * alpha * at * (2.0 - at) * (-at).exp();
299 if r <= 0.0 {
300 return prompt_slope;
301 }
302 (1.0 - r) * prompt_slope + r * beta * (prompt - storage_density(alpha, beta, tau))
303}
304
305#[must_use]
322pub fn ic_cdf(alpha: f64, beta: f64, r: f64, tau: f64) -> f64 {
323 if !alpha.is_finite() || !beta.is_finite() || !r.is_finite() {
324 return f64::NAN;
325 }
326 if tau.is_nan() || tau <= 0.0 {
327 return 0.0;
328 }
329 if tau == f64::INFINITY {
330 return 1.0;
331 }
332
333 let alpha = alpha.max(MIN_RATE);
334 let beta = beta.max(MIN_RATE);
335 let at = alpha * tau;
336 let fast = gamma3_cdf(at);
337 if r <= 0.0 {
338 return fast;
339 }
340
341 const CDF_TAIL_LIMIT: f64 = 1.0e100;
348 if at >= CDF_TAIL_LIMIT {
349 return (1.0 - r * (-beta * tau).exp()).clamp(0.0, 1.0);
350 }
351 if beta * tau >= CDF_TAIL_LIMIT {
352 return fast;
353 }
354
355 let u = (alpha - beta) * tau;
358 let correction = if u.abs() < 0.05 {
359 at.powi(3) * (-beta * tau).exp() * h_over_cube_taylor(u)
360 } else {
361 let bracket = (-beta * tau).exp() - (-alpha * tau).exp() * (1.0 + u + 0.5 * u * u);
363 at.powi(3) * bracket / u.powi(3)
364 };
365 (fast - r * correction).clamp(0.0, 1.0)
366}
367
368fn ic_cdf_slopes(alpha: f64, beta: f64, r: f64, tau: f64) -> [f64; 3] {
369 if !alpha.is_finite() || !beta.is_finite() || !r.is_finite() {
370 return [f64::NAN; 3];
371 }
372 if tau.is_nan() || tau <= 0.0 || tau == f64::INFINITY {
373 return [0.0; 3];
374 }
375 let at = alpha * tau;
376 let fast = 0.5 * tau * at * at * (-at).exp();
377 const CDF_TAIL_LIMIT: f64 = 1.0e100;
378 if at >= CDF_TAIL_LIMIT {
379 let storage = (-beta * tau).exp();
380 return [0.0, r * tau * storage, -storage];
381 }
382 if beta * tau >= CDF_TAIL_LIMIT {
383 return [fast, 0.0, 0.0];
384 }
385 let u = (alpha - beta) * tau;
386 let (correction, slope) = if u.abs() < 0.05 {
387 let storage = (-beta * tau).exp();
388 (
389 at.powi(3) * storage * h_over_cube_taylor(u),
390 -tau * storage * exponential_tail_series(u, 4),
391 )
392 } else {
393 let prompt = (-alpha * tau).exp();
394 let bracket = (-beta * tau).exp() - prompt * (1.0 + u + 0.5 * u * u);
395 (
396 at.powi(3) * bracket / u.powi(3),
397 tau * (prompt / (2.0 * u) - 3.0 * bracket / u.powi(4)),
398 )
399 };
400 let moved = at.powi(3) * slope;
401 [
402 fast - r * (3.0 * correction / alpha + moved),
403 r * (tau * correction + moved),
404 -correction,
405 ]
406}
407
408const GAUSS_LEGENDRE_16: [(f64, f64); 8] = [
409 (0.095_012_509_837_637_44, 0.189_450_610_455_068_59),
410 (0.281_603_550_779_258_9, 0.182_603_415_044_923_6),
411 (0.458_016_777_657_227_37, 0.169_156_519_395_002_62),
412 (0.617_876_244_402_643_8, 0.149_595_988_816_576_65),
413 (0.755_404_408_355_003_1, 0.124_628_971_255_533_95),
414 (0.865_631_202_387_831_6, 0.095_158_511_682_493),
415 (0.944_575_023_073_232_6, 0.062_253_523_938_647_61),
416 (0.989_400_934_991_649_9, 0.027_152_459_411_754_055),
417];
418
419fn fold_nodes(alpha: f64, fwhm_us: f64, delay_us: f64, mut node: impl FnMut(f64, f64)) {
420 let end = delay_us.min(fwhm_us);
421 let finest = 2.0 / alpha.max(MIN_RATE);
422 for (lo, hi) in [(-fwhm_us, end.min(0.0)), (0.0, end)] {
423 let (mut right, mut width) = (hi, finest);
424 while right > lo {
425 let left = (right - width).max(lo);
426 let (centre, half) = (0.5 * (left + right), 0.5 * (right - left));
427 for &(position, weight) in &GAUSS_LEGENDRE_16 {
428 for s in [centre - half * position, centre + half * position] {
429 node(s, half * weight);
430 }
431 }
432 right = left;
433 width *= 2.0;
434 }
435 }
436}
437
438fn triangle_fold<const N: usize>(
439 alpha: f64,
440 fwhm_us: f64,
441 delay_us: f64,
442 g: impl Fn(f64) -> [f64; N],
443) -> [f64; N] {
444 let mut total = [0.0; N];
445 fold_nodes(alpha, fwhm_us, delay_us, |s, weight| {
446 for (sum, value) in total.iter_mut().zip(g(delay_us - s)) {
447 *sum += weight * (fwhm_us - s.abs()) / (fwhm_us * fwhm_us) * value;
448 }
449 });
450 total
451}
452
453fn folded_slopes(pulse: &Recorded, fwhm_us: f64, delay_us: f64) -> [f64; 4] {
454 let mut slopes = [0.0; 4];
455 fold_nodes(pulse.alpha, fwhm_us, delay_us, |s, weight| {
456 let x = delay_us - s;
457 let t = (s / fwhm_us).abs();
458 let triangle = weight * (1.0 - t) / fwhm_us;
459 let values = pulse.slopes(x);
460 for (slope, value) in slopes.iter_mut().zip(&values[..3]) {
461 *slope += triangle * value;
462 }
463 let moment = (1.0 / 6.0 - 0.5 * t * t + t.powi(3) / 3.0) / (2.0 * fwhm_us);
464 slopes[3] += weight * moment * values[3];
465 });
466 slopes
467}
468
469#[derive(Debug, Clone, Copy)]
473struct Recorded {
474 alpha: f64,
475 beta: f64,
476 r: f64,
477 tail: Option<(f64, f64)>,
478}
479
480impl Recorded {
481 fn delayed(&self) -> Option<(f64, f64)> {
482 self.tail.filter(|&(fraction, _)| fraction != 0.0)
483 }
484
485 fn cdf(&self, tau: f64) -> f64 {
486 let plain = ic_cdf(self.alpha, self.beta, self.r, tau);
487 match self.delayed() {
488 None => plain,
489 Some((f, rate)) => ((1.0 - f) * plain + f * self.late(rate, tau)[0]).clamp(0.0, 1.0),
490 }
491 }
492
493 fn density(&self, tau: f64) -> f64 {
494 let plain = ic_pulse(self.alpha, self.beta, self.r, tau);
495 match self.delayed() {
496 None => plain,
497 Some((f, rate)) => (1.0 - f) * plain + f * self.late(rate, tau)[5],
498 }
499 }
500
501 fn slopes(&self, tau: f64) -> [f64; 4] {
504 let [a, b, c] = ic_cdf_slopes(self.alpha, self.beta, self.r, tau);
505 let plain = [a, b, c, ic_pulse_slope(self.alpha, self.beta, self.r, tau)];
506 match self.delayed() {
507 None => plain,
508 Some((f, rate)) => {
509 let late = self.late(rate, tau);
510 let late = [late[1], late[2], late[3], late[6]];
511 std::array::from_fn(|n| (1.0 - f) * plain[n] + f * late[n])
512 }
513 }
514 }
515
516 fn tail_slopes(&self, tau: f64) -> [f64; 2] {
518 let Some((f, rate)) = self.tail else {
519 return [0.0; 2];
520 };
521 let late = self.late(rate, tau);
522 [
523 late[0] - ic_cdf(self.alpha, self.beta, self.r, tau),
524 -f * rate * rate * late[4],
525 ]
526 }
527
528 fn late(&self, rate: f64, tau: f64) -> [f64; 7] {
529 delayed(self.alpha, self.beta, self.r, rate, tau)
530 }
531}
532
533const NEAR_RATE: f64 = 1e-3;
536
537fn delayed(alpha: f64, beta: f64, r: f64, rate: f64, tau: f64) -> [f64; 7] {
544 let gap = (rate - beta) / (NEAR_RATE * beta);
545 if gap.abs() >= 1.0 {
546 return delayed_apart(alpha, beta, r, rate, tau);
547 }
548 let mut total = [0.0; 7];
549 for (node, weight) in [
550 (-2.0, (gap * gap - 1.0) * (gap - 2.0) / -12.0),
551 (-1.0, (gap + 2.0) * (gap - 1.0) * (gap - 2.0) / 6.0),
552 (1.0, (gap + 2.0) * (gap + 1.0) * (gap - 2.0) / -6.0),
553 (2.0, (gap + 2.0) * (gap * gap - 1.0) / 12.0),
554 ] {
555 let values = delayed_apart(alpha, beta, r, beta * (1.0 + node * NEAR_RATE), tau);
556 for (sum, value) in total.iter_mut().zip(values) {
557 *sum += weight * value;
558 }
559 }
560 total
561}
562
563fn delayed_apart(alpha: f64, beta: f64, r: f64, rate: f64, tau: f64) -> [f64; 7] {
564 if tau.is_nan() || tau <= 0.0 {
565 return [0.0; 7];
566 }
567 if tau == f64::INFINITY {
568 return [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
569 }
570 let alpha = alpha.max(MIN_RATE);
571 let at = alpha * tau;
572 let (whole, prompt) = (gamma3_cdf(at), 0.5 * alpha * at * at * (-at).exp());
573 let storage = |x: f64| {
574 let [by_alpha, by_rate, lost] = ic_cdf_slopes(alpha, x, 1.0, tau);
575 let density = -x * lost;
576 [
577 whole + lost,
578 by_alpha,
579 by_rate,
580 density,
581 x * (prompt - density),
582 ]
583 };
584 let (at_beta, at_rate) = (storage(beta), storage(rate));
585 let gap = rate - beta;
586 let apart: [f64; 5] = std::array::from_fn(|n| (at_beta[n] - at_rate[n]) / gap);
587 [
588 at_rate[0] + r * rate * apart[0],
589 at_rate[1] + r * rate * apart[1],
590 r * rate * (at_beta[2] + apart[0]) / gap,
591 rate * apart[0],
592 at_rate[2] + r * apart[0] - r * rate * (at_rate[2] + apart[0]) / gap,
593 at_rate[3] + r * rate * apart[3],
594 at_rate[4] + r * rate * apart[4],
595 ]
596}
597
598fn bin_differences<const N: usize>(
601 edges_us: &[f64],
602 arrival_us: f64,
603 g: impl Fn(f64) -> [f64; N],
604) -> [Vec<f64>; N] {
605 let at_edges: Vec<[f64; N]> = edges_us.iter().map(|edge| g(edge - arrival_us)).collect();
606 std::array::from_fn(|n| at_edges.windows(2).map(|w| w[1][n] - w[0][n]).collect())
607}
608
609fn gamma3_cdf(x: f64) -> f64 {
611 if x <= 0.0 {
612 return 0.0;
613 }
614 if x >= 750.0 {
618 return 1.0;
619 }
620 if x < 0.5 {
621 let mut n = 0_u32;
624 let mut term = x.powi(3) / 6.0;
625 let mut sum = term;
626 loop {
627 let n_f = f64::from(n);
628 term *= -x * (n_f + 3.0) / ((n_f + 1.0) * (n_f + 4.0));
629 let next = sum + term;
630 n += 1;
631 if next == sum || n >= 128 {
632 return next.clamp(0.0, 1.0);
633 }
634 sum = next;
635 }
636 }
637 (1.0 - (-x).exp() * (1.0 + x + 0.5 * x * x)).clamp(0.0, 1.0)
638}
639
640#[derive(Debug, Clone, PartialEq)]
646pub enum EnergyLaw {
647 Const(f64),
649 SqrtE { a0: f64, a1: f64 },
651 InverseLambda { a0: f64, a1: f64 },
654 ExpMilliEv { kappa: f64 },
656 SqrtEBetween {
659 low: f64,
660 high: f64,
661 span_ev: (f64, f64),
662 },
663}
664
665impl EnergyLaw {
666 #[must_use]
669 pub fn eval(&self, energy_ev: f64) -> f64 {
670 let e = energy_ev.max(0.0);
671 match *self {
672 EnergyLaw::Const(c) => c,
673 EnergyLaw::SqrtE { a0, a1 } => a0 * e.sqrt() + a1,
674 EnergyLaw::InverseLambda { a0, a1 } => {
675 let denom = inverse_lambda_denom(a0, a1, e);
676 if denom.abs() < MIN_RATE {
677 1.0 / MIN_RATE
678 } else {
679 1.0 / denom
680 }
681 }
682 EnergyLaw::ExpMilliEv { kappa } => {
683 if kappa.abs() < MIN_RATE {
684 0.0
685 } else {
686 (-(e * 1000.0) / kappa).exp()
687 }
688 }
689 EnergyLaw::SqrtEBetween {
690 low,
691 high,
692 span_ev: (e_low, e_high),
693 } => {
694 let share = (e.sqrt() - e_low.sqrt()) / (e_high.sqrt() - e_low.sqrt());
695 (low + (high - low) * share).clamp(low.min(high), low.max(high))
696 }
697 }
698 }
699
700 #[must_use]
709 pub(crate) fn is_singular_at(&self, energy_ev: f64) -> bool {
710 match *self {
711 EnergyLaw::InverseLambda { a0, a1 } => {
712 inverse_lambda_denom(a0, a1, energy_ev.max(0.0)).abs() < MIN_RATE
713 }
714 EnergyLaw::ExpMilliEv { kappa } => (-MIN_RATE..=0.0).contains(&kappa),
718 _ => false,
719 }
720 }
721}
722
723fn inverse_lambda_denom(a0: f64, a1: f64, e: f64) -> f64 {
726 let lambda = if e > 0.0 {
727 LAMBDA_ANGSTROM_FACTOR / e.sqrt()
728 } else {
729 f64::INFINITY
730 };
731 a0 + a1 * lambda
732}
733
734#[derive(Debug, Clone, PartialEq)]
736pub struct IkedaCarpenterParams {
737 pub alpha: EnergyLaw,
740 pub beta: EnergyLaw,
742 pub r: EnergyLaw,
744 pub burst_sigma_us: Option<f64>,
747 pub channel_fwhm_us: Option<f64>,
749 pub detector_tail: Option<DetectorTail>,
752}
753
754#[derive(Debug, Clone, Copy, PartialEq)]
757pub struct DetectorTail {
758 pub fraction: f64,
760 pub decay_us: f64,
762}
763
764impl IkedaCarpenterParams {
765 #[must_use]
768 pub fn constant(alpha: f64, beta: f64, r: f64) -> Self {
769 Self {
770 alpha: EnergyLaw::Const(alpha),
771 beta: EnergyLaw::Const(beta),
772 r: EnergyLaw::Const(r),
773 burst_sigma_us: None,
774 channel_fwhm_us: None,
775 detector_tail: None,
776 }
777 }
778
779 fn tail_rate(&self) -> Result<Option<(f64, f64)>, ResolutionParseError> {
780 let Some(tail) = self.detector_tail else {
781 return Ok(None);
782 };
783 if !((0.0..=1.0).contains(&tail.fraction)
784 && DETECTOR_TAIL_DECAY_US.contains(&tail.decay_us))
785 {
786 return Err(ResolutionParseError::InvalidFormat(format!(
787 "a detector tail needs a fraction within 0–1 and a decay from {:e} to {:e} µs, \
788 got {tail:?}",
789 DETECTOR_TAIL_DECAY_US.start(),
790 DETECTOR_TAIL_DECAY_US.end()
791 )));
792 }
793 Ok(Some((tail.fraction, 1.0 / tail.decay_us)))
794 }
795
796 fn recorded_at(&self, energy_ev: f64) -> Result<Recorded, ResolutionParseError> {
797 self.validate_probe_energy(energy_ev)?;
798 Ok(Recorded {
799 alpha: self.alpha.eval(energy_ev),
800 beta: self.beta.eval(energy_ev),
801 r: self.r.eval(energy_ev),
802 tail: self.tail_rate()?,
803 })
804 }
805
806 fn triangle_fwhm_us(&self) -> Result<f64, ResolutionParseError> {
807 if self.burst_sigma_us.unwrap_or(0.0) != 0.0 {
808 return Err(ResolutionParseError::InvalidFormat(
809 "a Gaussian burst is not supported in detector time; model the proton \
810 pulse with channel_fwhm_us"
811 .to_string(),
812 ));
813 }
814 let fwhm_us = self.channel_fwhm_us.unwrap_or(0.0);
815 if !(fwhm_us.is_finite() && fwhm_us >= 0.0) {
816 return Err(ResolutionParseError::InvalidFormat(format!(
817 "the triangle's FWHM must be finite and 0 or more, got {fwhm_us} µs"
818 )));
819 }
820 Ok(fwhm_us)
821 }
822
823 pub fn delays_us(&self, energy_ev: f64) -> Result<(f64, f64), ResolutionParseError> {
835 let pulse = self.recorded_at(energy_ev)?;
836 let h = self.triangle_fwhm_us()?;
837 Ok((-h, tail_delay(&pulse) + h))
838 }
839
840 pub fn bin_probabilities_at(
847 &self,
848 true_energy_ev: f64,
849 arrival_us: f64,
850 detector_time_edges_us: &[f64],
851 ) -> Result<Vec<f64>, ResolutionParseError> {
852 let (pulse, h) = self.at_arrival(true_energy_ev, arrival_us, detector_time_edges_us)?;
853 let cdf = |x: f64| {
854 if h == 0.0 {
855 pulse.cdf(x)
856 } else {
857 triangle_fold(pulse.alpha, h, x, |tau| [pulse.cdf(tau)])[0].clamp(0.0, 1.0)
858 }
859 };
860 let cdfs: Vec<f64> = detector_time_edges_us
861 .iter()
862 .map(|edge| cdf(edge - arrival_us))
863 .collect();
864 Ok(cdfs.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect())
865 }
866
867 pub fn bin_arrival_slopes_at(
874 &self,
875 true_energy_ev: f64,
876 arrival_us: f64,
877 detector_time_edges_us: &[f64],
878 ) -> Result<Vec<f64>, ResolutionParseError> {
879 let (pulse, h) = self.at_arrival(true_energy_ev, arrival_us, detector_time_edges_us)?;
880 let density = |x: f64| {
881 if h == 0.0 {
882 pulse.density(x)
883 } else {
884 triangle_fold(pulse.alpha, h, x, |tau| [pulse.density(tau)])[0]
885 }
886 };
887 let densities: Vec<f64> = detector_time_edges_us
888 .iter()
889 .map(|edge| density(edge - arrival_us))
890 .collect();
891 Ok(densities.windows(2).map(|w| w[0] - w[1]).collect())
892 }
893
894 pub fn bin_pulse_slopes_at(
903 &self,
904 true_energy_ev: f64,
905 arrival_us: f64,
906 detector_time_edges_us: &[f64],
907 ) -> Result<[Vec<f64>; 4], ResolutionParseError> {
908 let (pulse, h) = self.at_arrival(true_energy_ev, arrival_us, detector_time_edges_us)?;
909 let slopes = |x: f64| {
910 if h == 0.0 {
911 let [a, b, c, d] = pulse.slopes(x);
912 [a, b, c, d / 12.0]
913 } else {
914 folded_slopes(&pulse, h, x)
915 }
916 };
917 Ok(bin_differences(detector_time_edges_us, arrival_us, slopes))
918 }
919
920 pub fn bin_tail_slopes_at(
929 &self,
930 true_energy_ev: f64,
931 arrival_us: f64,
932 detector_time_edges_us: &[f64],
933 ) -> Result<[Vec<f64>; 2], ResolutionParseError> {
934 let (pulse, h) = self.at_arrival(true_energy_ev, arrival_us, detector_time_edges_us)?;
935 if pulse.tail.is_none() {
936 return Err(ResolutionParseError::InvalidFormat(
937 "the pulse has no detector tail".to_string(),
938 ));
939 }
940 let slopes = |x: f64| {
941 if h == 0.0 {
942 pulse.tail_slopes(x)
943 } else {
944 triangle_fold(pulse.alpha, h, x, |tau| pulse.tail_slopes(tau))
945 }
946 };
947 Ok(bin_differences(detector_time_edges_us, arrival_us, slopes))
948 }
949
950 fn at_arrival(
951 &self,
952 true_energy_ev: f64,
953 arrival_us: f64,
954 detector_time_edges_us: &[f64],
955 ) -> Result<(Recorded, f64), ResolutionParseError> {
956 let pulse = self.recorded_at(true_energy_ev)?;
957 if !arrival_us.is_finite() {
958 return Err(ResolutionParseError::InvalidFormat(format!(
959 "the nominal arrival must be finite, got {arrival_us} µs"
960 )));
961 }
962 if detector_time_edges_us.len() < 2
963 || detector_time_edges_us.iter().any(|x| !x.is_finite())
964 || detector_time_edges_us.windows(2).any(|w| w[0] >= w[1])
965 {
966 return Err(ResolutionParseError::InvalidFormat(
967 "detector time edges must contain at least two finite, strictly increasing values"
968 .to_string(),
969 ));
970 }
971 Ok((pulse, self.triangle_fwhm_us()?))
972 }
973
974 fn validate_probe_energy(&self, energy_ev: f64) -> Result<(), ResolutionParseError> {
975 if !energy_ev.is_finite() || energy_ev <= 0.0 {
976 return Err(ResolutionParseError::InvalidFormat(format!(
977 "true energy must be positive and finite, got {energy_ev}"
978 )));
979 }
980 for (name, law) in [("alpha", &self.alpha), ("beta", &self.beta), ("R", &self.r)] {
981 if law.is_singular_at(energy_ev) {
982 return Err(ResolutionParseError::InvalidFormat(format!(
983 "Ikeda–Carpenter {name}({energy_ev}) law is singular (an \
984 InverseLambda denominator within ±{MIN_RATE} of zero, \
985 or an ExpMilliEv κ in [−{MIN_RATE}, 0])"
986 )));
987 }
988 }
989 let alpha = self.alpha.eval(energy_ev);
990 let beta = self.beta.eval(energy_ev);
991 let r = self.r.eval(energy_ev);
992 if !alpha.is_finite() || alpha < MIN_RATE {
993 return Err(ResolutionParseError::InvalidFormat(format!(
994 "Ikeda–Carpenter alpha({energy_ev}) must be finite and at least {MIN_RATE} \
995 µs⁻¹, got {alpha}"
996 )));
997 }
998 if !beta.is_finite() || beta < MIN_RATE {
999 return Err(ResolutionParseError::InvalidFormat(format!(
1000 "Ikeda–Carpenter beta({energy_ev}) must be finite and at least {MIN_RATE} \
1001 µs⁻¹, got {beta}"
1002 )));
1003 }
1004 if !r.is_finite() || !(0.0..=1.0).contains(&r) {
1005 return Err(ResolutionParseError::InvalidFormat(format!(
1006 "Ikeda–Carpenter R({energy_ev}) must be in [0, 1], got {r}"
1007 )));
1008 }
1009 Ok(())
1010 }
1011}
1012
1013#[derive(Debug, Clone)]
1017pub struct SynthesisGrid {
1018 pub e_min_ev: f64,
1020 pub e_max_ev: f64,
1022 pub n_energies: usize,
1024 pub n_tau: usize,
1035}
1036
1037impl SynthesisGrid {
1038 #[must_use]
1041 pub fn new(e_min_ev: f64, e_max_ev: f64) -> Self {
1042 Self {
1043 e_min_ev,
1044 e_max_ev,
1045 n_energies: DEFAULT_N_ENERGIES,
1046 n_tau: DEFAULT_N_TAU,
1047 }
1048 }
1049}
1050
1051#[derive(Debug, Clone)]
1058pub struct IkedaCarpenter {
1059 params: IkedaCarpenterParams,
1060 flight_path_m: f64,
1061 ref_energies: Arc<Vec<f64>>,
1063 n_tau: usize,
1064 tabulated: TabulatedResolution,
1065}
1066
1067impl IkedaCarpenter {
1068 pub fn new(
1081 params: IkedaCarpenterParams,
1082 flight_path_m: f64,
1083 grid: &SynthesisGrid,
1084 ) -> Result<Self, ResolutionParseError> {
1085 if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
1086 return Err(ResolutionParseError::InvalidFormat(format!(
1087 "Flight path must be a positive finite number, got {flight_path_m}"
1088 )));
1089 }
1090 if grid.n_energies < 2 {
1091 return Err(ResolutionParseError::InvalidFormat(format!(
1092 "n_energies must be >= 2, got {}",
1093 grid.n_energies
1094 )));
1095 }
1096 if grid.n_tau < MIN_N_TAU {
1097 return Err(ResolutionParseError::InvalidFormat(format!(
1098 "n_tau must be >= {MIN_N_TAU}, got {}",
1099 grid.n_tau
1100 )));
1101 }
1102 if !grid.e_min_ev.is_finite()
1103 || !grid.e_max_ev.is_finite()
1104 || grid.e_min_ev <= 0.0
1105 || grid.e_max_ev <= grid.e_min_ev
1106 {
1107 return Err(ResolutionParseError::InvalidFormat(format!(
1108 "Require finite 0 < e_min < e_max, got [{}, {}]",
1109 grid.e_min_ev, grid.e_max_ev
1110 )));
1111 }
1112 let ln_lo = grid.e_min_ev.ln();
1113 let ln_hi = grid.e_max_ev.ln();
1114 let denom = (grid.n_energies - 1) as f64;
1115 let ref_energies: Vec<f64> = (0..grid.n_energies)
1116 .map(|i| (ln_lo + (i as f64 / denom) * (ln_hi - ln_lo)).exp())
1117 .collect();
1118
1119 for (name, law) in [("α", ¶ms.alpha), ("β", ¶ms.beta), ("R", ¶ms.r)] {
1125 if let Some(&bad) = ref_energies.iter().find(|&&e| law.is_singular_at(e)) {
1126 return Err(ResolutionParseError::InvalidFormat(format!(
1127 "Ikeda–Carpenter {name}(E) law is singular at E = {bad} eV \
1128 (an InverseLambda denominator within \
1129 ±{MIN_RATE} of zero, or an ExpMilliEv κ in [−{MIN_RATE}, 0])"
1130 )));
1131 }
1132 }
1133 if let Some(&bad) = ref_energies.iter().find(|&&e| {
1134 let a = params.alpha.eval(e);
1135 !a.is_finite() || a < MIN_RATE
1136 }) {
1137 return Err(ResolutionParseError::InvalidFormat(format!(
1138 "Ikeda–Carpenter α(E) must be at least {MIN_RATE} µs⁻¹, but α({bad}) = {} is not",
1139 params.alpha.eval(bad)
1140 )));
1141 }
1142 if let Some(&bad) = ref_energies.iter().find(|&&e| {
1146 let beta = params.beta.eval(e);
1147 !beta.is_finite() || beta < MIN_RATE
1148 }) {
1149 return Err(ResolutionParseError::InvalidFormat(format!(
1150 "Ikeda–Carpenter β(E) must be at least {MIN_RATE} µs⁻¹, but β({bad}) = {} is not",
1151 params.beta.eval(bad)
1152 )));
1153 }
1154 if let Some(&bad) = ref_energies.iter().find(|&&e| {
1157 let r = params.r.eval(e);
1158 !r.is_finite() || !(0.0..=1.0).contains(&r)
1159 }) {
1160 return Err(ResolutionParseError::InvalidFormat(format!(
1161 "Ikeda–Carpenter R(E) must be in [0, 1], but R({bad}) = {} is not",
1162 params.r.eval(bad)
1163 )));
1164 }
1165 for (name, v) in [
1168 ("burst_sigma_us", params.burst_sigma_us),
1169 ("channel_fwhm_us", params.channel_fwhm_us),
1170 ] {
1171 if let Some(x) = v
1172 && (!x.is_finite() || x < 0.0)
1173 {
1174 return Err(ResolutionParseError::InvalidFormat(format!(
1175 "Ikeda–Carpenter {name} must be finite and >= 0, got {x}"
1176 )));
1177 }
1178 }
1179 if params.detector_tail.is_some() {
1180 return Err(ResolutionParseError::InvalidFormat(
1181 "a detector tail is not supported in the kernel table".to_string(),
1182 ));
1183 }
1184
1185 let kernels: Vec<(Vec<f64>, Vec<f64>)> = ref_energies
1190 .iter()
1191 .map(|&e| synth_kernel(¶ms, grid.n_tau, e))
1192 .collect::<Result<_, _>>()?;
1193
1194 let tabulated =
1195 TabulatedResolution::from_kernels(ref_energies.clone(), kernels, flight_path_m)?;
1196
1197 Ok(Self {
1198 params,
1199 flight_path_m,
1200 ref_energies: Arc::new(ref_energies),
1201 n_tau: grid.n_tau,
1202 tabulated,
1203 })
1204 }
1205
1206 #[must_use]
1208 pub fn tabulated(&self) -> &TabulatedResolution {
1209 &self.tabulated
1210 }
1211
1212 #[must_use]
1214 pub fn params(&self) -> &IkedaCarpenterParams {
1215 &self.params
1216 }
1217
1218 #[must_use]
1220 pub fn flight_path_m(&self) -> f64 {
1221 self.flight_path_m
1222 }
1223
1224 pub fn with_flight_path(&self, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
1235 Ok(Self {
1236 params: self.params.clone(),
1237 flight_path_m,
1238 ref_energies: Arc::clone(&self.ref_energies),
1239 n_tau: self.n_tau,
1240 tabulated: self.tabulated.with_flight_path(flight_path_m)?,
1241 })
1242 }
1243
1244 #[must_use]
1246 pub fn ref_energies(&self) -> &[f64] {
1247 &self.ref_energies
1248 }
1249
1250 #[must_use]
1253 pub fn detector_pulse(&self) -> DetectorPulse {
1254 DetectorPulse {
1255 params: self.params.clone(),
1256 energy_span_ev: (
1257 self.ref_energies[0],
1258 self.ref_energies[self.ref_energies.len() - 1],
1259 ),
1260 n_tau: self.n_tau,
1261 }
1262 }
1263
1264 pub fn kernel_at(&self, energy_ev: f64) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1278 self.params.validate_probe_energy(energy_ev)?;
1279 synth_kernel(&self.params, self.n_tau, energy_ev)
1280 }
1281
1282 pub fn source_pulse_at(
1296 &self,
1297 energy_ev: f64,
1298 ) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1299 self.params.validate_probe_energy(energy_ev)?;
1300 synth_source_pulse(&self.params, self.n_tau, energy_ev)
1301 }
1302
1303 pub fn detector_bin_probabilities(
1325 &self,
1326 true_energy_ev: f64,
1327 detector_time_edges_us: &[f64],
1328 timing_offset_us: f64,
1329 ) -> Result<Vec<f64>, ResolutionParseError> {
1330 if !timing_offset_us.is_finite() {
1331 return Err(ResolutionParseError::InvalidFormat(format!(
1332 "timing_offset_us must be finite, got {timing_offset_us}"
1333 )));
1334 }
1335 self.params.bin_probabilities_at(
1336 true_energy_ev,
1337 timing_offset_us + TOF_FACTOR * self.flight_path_m / true_energy_ev.sqrt(),
1338 detector_time_edges_us,
1339 )
1340 }
1341}
1342
1343#[derive(Debug, Clone)]
1347pub struct DetectorPulse {
1348 params: IkedaCarpenterParams,
1349 energy_span_ev: (f64, f64),
1350 n_tau: usize,
1351}
1352
1353impl DetectorPulse {
1354 pub fn new(
1365 params: IkedaCarpenterParams,
1366 energy_span_ev: (f64, f64),
1367 n_tau: usize,
1368 ) -> Result<Self, ResolutionParseError> {
1369 params.triangle_fwhm_us()?;
1370 params.tail_rate()?;
1371 let (low, high) = energy_span_ev;
1372 if !(low.is_finite() && high.is_finite() && 0.0 < low && low < high) {
1373 return Err(ResolutionParseError::InvalidFormat(format!(
1374 "the energy span must satisfy 0 < low < high, got {energy_span_ev:?}"
1375 )));
1376 }
1377 params.validate_probe_energy(low)?;
1378 params.validate_probe_energy(high)?;
1379 if n_tau < MIN_N_TAU {
1380 return Err(ResolutionParseError::InvalidFormat(format!(
1381 "n_tau must be >= {MIN_N_TAU}, got {n_tau}"
1382 )));
1383 }
1384 Ok(Self {
1385 params,
1386 energy_span_ev,
1387 n_tau,
1388 })
1389 }
1390
1391 #[must_use]
1393 pub fn params(&self) -> &IkedaCarpenterParams {
1394 &self.params
1395 }
1396
1397 #[must_use]
1399 pub fn energy_span_ev(&self) -> (f64, f64) {
1400 self.energy_span_ev
1401 }
1402
1403 #[must_use]
1405 pub fn n_tau(&self) -> usize {
1406 self.n_tau
1407 }
1408
1409 pub fn rise_us(&self, energy_ev: f64) -> Result<f64, ResolutionParseError> {
1417 self.params.validate_probe_energy(energy_ev)?;
1418 self.params.triangle_fwhm_us()?;
1419 sampled_rise_us(&self.params, self.n_tau, energy_ev)
1420 }
1421}
1422
1423pub const NEGLIGIBLE_ARRIVAL_PROBABILITY: f64 = 1e-7;
1426
1427fn tail_delay(pulse: &Recorded) -> f64 {
1428 let later = |tau: f64| 1.0 - pulse.cdf(tau);
1429 let late = pulse.delayed().map_or(f64::INFINITY, |(_, rate)| rate);
1430 let mut high = 1.0 / pulse.alpha.min(pulse.beta).min(late).max(MIN_RATE);
1431 while later(high) > NEGLIGIBLE_ARRIVAL_PROBABILITY {
1432 high *= 2.0;
1433 }
1434 let mut low = 0.0;
1435 while high - low > f64::EPSILON * high {
1436 let middle = 0.5 * (low + high);
1437 if later(middle) > NEGLIGIBLE_ARRIVAL_PROBABILITY {
1438 low = middle;
1439 } else {
1440 high = middle;
1441 }
1442 }
1443 high
1444}
1445
1446fn requested_step(params: &IkedaCarpenterParams, n_tau: usize, alpha: f64) -> (f64, f64, String) {
1447 let fast_reach = FAST_REACH_E_FOLDS / alpha;
1448 let mut dtau_req = fast_reach / (n_tau as f64 - 1.0);
1449 let mut floor = fast_reach / (MIN_N_TAU as f64 - 1.0);
1450 let mut fold_desc = String::new();
1451 if let Some(fwhm) = params.channel_fwhm_us
1452 && fwhm > 0.0
1453 {
1454 let tri_floor = fwhm / TRI_MIN_SAMPLES_PER_SIDE;
1455 dtau_req = dtau_req.min(tri_floor);
1456 floor = floor.min(tri_floor);
1457 fold_desc.push_str(&format!(", channel triangle FWHM = {fwhm} µs"));
1458 }
1459 if let Some(sigma) = params.burst_sigma_us
1460 && sigma > 0.0
1461 {
1462 dtau_req = dtau_req.min(sigma);
1463 floor = floor.min(sigma);
1464 fold_desc.push_str(&format!(", burst σ = {sigma} µs"));
1465 }
1466
1467 (dtau_req, floor, fold_desc)
1468}
1469
1470fn tau_geometry(
1493 params: &IkedaCarpenterParams,
1494 n_tau: usize,
1495 alpha: f64,
1496 beta: f64,
1497 r: f64,
1498) -> Result<(f64, f64, f64), String> {
1499 let tau_max = tau_reach(alpha, beta, r);
1500 let (dtau_req, floor, fold_desc) = requested_step(params, n_tau, alpha);
1501
1502 let capped_step = tau_max / (MAX_TAU_SAMPLES as f64 - 1.0);
1503 if capped_step > floor {
1504 return Err(format!(
1505 "the {MAX_TAU_SAMPLES}-sample τ-grid cap cannot resolve the requested physics: \
1506 the pulse spans τ_max = {tau_max:.3} µs (α = {alpha:.4} µs⁻¹, β = {beta:.4} µs⁻¹, \
1507 R = {r:.3}{fold_desc}), forcing a τ-step of {capped_step:.4} µs — above the \
1508 finest-feature resolution floor of {floor:.4} µs. Increase β (shorter storage \
1509 tail), set R = 0 (drop the storage term), or widen/disable the burst/channel fold"
1510 ));
1511 }
1512 Ok((dtau_req.max(capped_step), tau_max, margin_of(params)))
1513}
1514
1515fn tau_reach(alpha: f64, beta: f64, r: f64) -> f64 {
1516 let fast_reach = FAST_REACH_E_FOLDS / alpha;
1517 if r > R_NEGLIGIBLE {
1518 fast_reach.max(SLOW_REACH_E_FOLDS / beta)
1519 } else {
1520 fast_reach
1521 }
1522}
1523
1524fn margin_of(params: &IkedaCarpenterParams) -> f64 {
1528 params
1529 .burst_sigma_us
1530 .map_or(0.0, |s| GAUSS_REACH_SIGMAS * s)
1531 + params.channel_fwhm_us.unwrap_or(0.0)
1532}
1533
1534fn synth_kernel(
1546 params: &IkedaCarpenterParams,
1547 n_tau: usize,
1548 energy_ev: f64,
1549) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1550 synth_source_pulse(params, n_tau, energy_ev)
1551}
1552
1553fn synth_source_pulse_density(
1555 params: &IkedaCarpenterParams,
1556 n_tau: usize,
1557 energy_ev: f64,
1558) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1559 let alpha = params.alpha.eval(energy_ev).max(MIN_RATE);
1560 let beta = params.beta.eval(energy_ev).max(MIN_RATE);
1561 let r = params.r.eval(energy_ev).clamp(0.0, 1.0);
1562
1563 let geometry = tau_geometry(params, n_tau, alpha, beta, r).map_err(|msg| {
1564 ResolutionParseError::InvalidFormat(format!(
1565 "Ikeda–Carpenter kernel at E = {energy_ev} eV: {msg}"
1566 ))
1567 })?;
1568 sampled_density(params, (alpha, beta, r), geometry, energy_ev)
1569}
1570
1571fn sampled_rise_us(
1572 params: &IkedaCarpenterParams,
1573 n_tau: usize,
1574 energy_ev: f64,
1575) -> Result<f64, ResolutionParseError> {
1576 let alpha = params.alpha.eval(energy_ev).max(MIN_RATE);
1577 let beta = params.beta.eval(energy_ev).max(MIN_RATE);
1578 let r = params.r.eval(energy_ev).clamp(0.0, 1.0);
1579 let fast_reach = FAST_REACH_E_FOLDS / alpha;
1580 let margin = margin_of(params);
1581 let (dtau_req, _, _) = requested_step(params, n_tau, alpha);
1582 let dtau = dtau_req.max((fast_reach + 2.0 * margin) / (MAX_TAU_SAMPLES as f64 - 1.0));
1583 let (times, densities) = sampled_density(
1584 params,
1585 (alpha, beta, r),
1586 (dtau, fast_reach, margin),
1587 energy_ev,
1588 )?;
1589 Ok(times[argmax(&densities)] - times[0])
1590}
1591
1592fn sampled_density(
1593 params: &IkedaCarpenterParams,
1594 (alpha, beta, r): (f64, f64, f64),
1595 (dtau, tau_max, margin): (f64, f64, f64),
1596 energy_ev: f64,
1597) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1598 let j_lo: isize = -((margin / dtau).ceil() as isize);
1601 let j_hi: isize = ((tau_max + margin) / dtau).ceil() as isize;
1602
1603 let taus: Vec<f64> = (j_lo..=j_hi).map(|j| j as f64 * dtau).collect();
1604 let mut weights: Vec<f64> = taus.iter().map(|&t| ic_pulse(alpha, beta, r, t)).collect();
1605
1606 let sampled_area = dtau
1607 * (0.5 * weights[0]
1608 + weights[1..weights.len() - 1].iter().sum::<f64>()
1609 + 0.5 * weights[weights.len() - 1]);
1610 if !sampled_area.is_finite() || sampled_area <= 0.0 {
1611 return Err(ResolutionParseError::InvalidFormat(format!(
1612 "Ikeda–Carpenter pulse at E = {energy_ev} eV has zero sampled area"
1613 )));
1614 }
1615
1616 if let Some(sigma) = params.burst_sigma_us
1617 && sigma > 0.0
1618 {
1619 weights = convolve_same(&weights, &gaussian_kernel(dtau, sigma));
1620 }
1621 if let Some(fwhm) = params.channel_fwhm_us
1622 && fwhm > 0.0
1623 {
1624 weights = convolve_same(&weights, &triangle_kernel(dtau, fwhm));
1625 }
1626
1627 let peak_idx = argmax(&weights);
1628 let peak_val = weights[peak_idx].max(f64::MIN_POSITIVE);
1629
1630 let thresh = TRIM_REL * peak_val;
1633 let lo = weights
1634 .iter()
1635 .position(|&w| w > thresh)
1636 .map_or(0, |i| i.saturating_sub(1));
1637 let hi = weights
1638 .iter()
1639 .rposition(|&w| w > thresh)
1640 .map_or(weights.len() - 1, |i| (i + 1).min(weights.len() - 1));
1641
1642 let offsets: Vec<f64> = (lo..=hi).map(|j| taus[j]).collect();
1643 let densities: Vec<f64> = (lo..=hi).map(|j| weights[j]).collect();
1644 Ok((offsets, densities))
1645}
1646
1647fn synth_source_pulse(
1649 params: &IkedaCarpenterParams,
1650 n_tau: usize,
1651 energy_ev: f64,
1652) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1653 let (offsets, densities) = synth_source_pulse_density(params, n_tau, energy_ev)?;
1654 let peak = densities
1655 .iter()
1656 .copied()
1657 .fold(0.0_f64, f64::max)
1658 .max(f64::MIN_POSITIVE);
1659 let weights = densities.into_iter().map(|value| value / peak).collect();
1660 Ok((offsets, weights))
1661}
1662
1663fn argmax(xs: &[f64]) -> usize {
1666 let mut best = 0;
1667 let mut best_v = xs[0];
1668 for (i, &x) in xs.iter().enumerate().skip(1) {
1669 if x > best_v {
1670 best_v = x;
1671 best = i;
1672 }
1673 }
1674 best
1675}
1676
1677fn gaussian_kernel(dtau: f64, sigma: f64) -> Vec<f64> {
1680 let half = ((GAUSS_REACH_SIGMAS * sigma / dtau).ceil() as isize).max(1);
1681 let mut k: Vec<f64> = (-half..=half)
1682 .map(|j| {
1683 let t = j as f64 * dtau / sigma;
1684 (-0.5 * t * t).exp()
1685 })
1686 .collect();
1687 normalize_sum(&mut k);
1688 k
1689}
1690
1691fn triangle_kernel(dtau: f64, fwhm: f64) -> Vec<f64> {
1694 let a = fwhm; let half = ((a / dtau).ceil() as isize).max(1);
1696 let mut k: Vec<f64> = (-half..=half)
1697 .map(|j| (1.0 - (j as f64 * dtau).abs() / a).max(0.0))
1698 .collect();
1699 normalize_sum(&mut k);
1700 k
1701}
1702
1703fn normalize_sum(k: &mut [f64]) {
1704 let s: f64 = k.iter().sum();
1705 if s > 0.0 {
1706 for v in k.iter_mut() {
1707 *v /= s;
1708 }
1709 }
1710}
1711
1712fn convolve_same(input: &[f64], kernel: &[f64]) -> Vec<f64> {
1715 let n = input.len();
1716 let kh = (kernel.len() / 2) as isize;
1717 let mut out = vec![0.0f64; n];
1718 for (i, o) in out.iter_mut().enumerate() {
1719 let mut acc = 0.0;
1720 for (kk, &kv) in kernel.iter().enumerate() {
1721 let src = i as isize + (kk as isize - kh);
1722 if src >= 0 && (src as usize) < n {
1723 acc += input[src as usize] * kv;
1724 }
1725 }
1726 *o = acc;
1727 }
1728 out
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733 use super::*;
1734 use crate::resolution::{
1735 ResolutionFunction, apply_resolution, apply_resolution_with_plan, build_resolution_plan,
1736 };
1737 use std::sync::Arc;
1738
1739 fn pulse_area(alpha: f64, beta: f64, r: f64) -> f64 {
1741 let tau_max = (18.0 / alpha).max(if r > 0.0 { 18.0 / beta } else { 0.0 });
1742 let n = 200_000;
1743 let dt = tau_max / n as f64;
1744 let mut area = 0.0;
1745 for i in 0..n {
1746 let t0 = i as f64 * dt;
1747 let t1 = (i + 1) as f64 * dt;
1748 area += 0.5 * (ic_pulse(alpha, beta, r, t0) + ic_pulse(alpha, beta, r, t1)) * dt;
1749 }
1750 area
1751 }
1752
1753 fn pulse_mean(alpha: f64, beta: f64, r: f64) -> f64 {
1754 let tau_max = (24.0 / alpha).max(if r > 0.0 { 24.0 / beta } else { 0.0 });
1755 let n = 400_000;
1756 let dt = tau_max / n as f64;
1757 let mut m = 0.0;
1758 for i in 0..n {
1759 let t = (i as f64 + 0.5) * dt;
1760 m += t * ic_pulse(alpha, beta, r, t) * dt;
1761 }
1762 m
1763 }
1764
1765 #[test]
1766 fn pulse_is_unit_area() {
1767 for &(a, b, r) in &[
1768 (0.5, 0.05, 0.0),
1769 (1.0, 0.1, 0.3),
1770 (2.0, 0.2, 0.6),
1771 (0.8, 0.5, 0.9),
1772 ] {
1773 let area = pulse_area(a, b, r);
1774 assert!(
1775 (area - 1.0).abs() < 1e-3,
1776 "area for (α={a},β={b},R={r}) = {area}, expected 1"
1777 );
1778 }
1779 }
1780
1781 #[test]
1782 fn pulse_mean_matches_formula() {
1783 for &(a, b, r) in &[(1.0, 0.1, 0.0), (1.0, 0.1, 0.4), (2.0, 0.25, 0.7)] {
1785 let want = 3.0 / a + r / b;
1786 let got = pulse_mean(a, b, r);
1787 assert!(
1788 (got - want).abs() / want < 2e-3,
1789 "mean (α={a},β={b},R={r}) = {got}, expected {want}"
1790 );
1791 }
1792 }
1793
1794 #[test]
1795 fn pulse_mode_is_two_over_alpha_for_pure_fast() {
1796 let alpha = 1.3;
1798 let mode = 2.0 / alpha;
1799 let here = ic_pulse(alpha, 0.1, 0.0, mode);
1800 for d in [-0.3, -0.1, 0.1, 0.3] {
1801 assert!(ic_pulse(alpha, 0.1, 0.0, mode + d) <= here + 1e-12);
1802 }
1803 }
1804
1805 #[test]
1806 fn pulse_alpha_equals_beta_is_finite_gamma4() {
1807 let a = 1.0;
1809 let tau = 2.5;
1810 let got = ic_pulse(a, a, 1.0, tau);
1811 let want = a.powi(4) * tau.powi(3) / 6.0 * (-a * tau).exp();
1812 assert!(got.is_finite());
1813 assert!(
1814 (got - want).abs() < 1e-9,
1815 "α=β pulse {got} != Gamma(4) {want}"
1816 );
1817 let near = ic_pulse(a, a - 1e-9, 1.0, tau);
1819 assert!(near.is_finite() && (near - want).abs() < 1e-6);
1820 }
1821
1822 #[test]
1823 fn pulse_is_finite_for_beta_much_greater_than_alpha() {
1824 for &tau in &[0.0, 1.0, 50.0, 400.0, 1000.0] {
1826 let v = ic_pulse(0.05, 4.0, 0.5, tau);
1827 assert!(
1828 v.is_finite() && v >= 0.0,
1829 "ic_pulse(0.05,4,0.5,{tau}) = {v}"
1830 );
1831 }
1832 let p = IkedaCarpenterParams {
1834 alpha: EnergyLaw::Const(0.05),
1835 beta: EnergyLaw::Const(4.0),
1836 r: EnergyLaw::Const(0.5),
1837 burst_sigma_us: None,
1838 channel_fwhm_us: None,
1839 detector_tail: None,
1840 };
1841 let (offs, wts) = synth_kernel(&p, 600, 1.0).unwrap();
1842 assert!(offs.iter().chain(wts.iter()).all(|v| v.is_finite()));
1843 }
1844
1845 #[test]
1846 fn pulse_is_nonnegative_and_zero_before_t0() {
1847 assert_eq!(ic_pulse(1.0, 0.1, 0.5, -0.5), 0.0);
1848 for i in 0..200 {
1849 let t = i as f64 * 0.1;
1850 assert!(ic_pulse(1.0, 0.1, 0.5, t) >= 0.0);
1851 }
1852 }
1853
1854 #[test]
1855 fn energy_law_eval() {
1856 assert_eq!(EnergyLaw::Const(3.0).eval(50.0), 3.0);
1857 let s = EnergyLaw::SqrtE { a0: 0.2, a1: 0.1 };
1858 assert!((s.eval(100.0) - (0.2 * 10.0 + 0.1)).abs() < 1e-12);
1859 let il = EnergyLaw::InverseLambda { a0: 0.1, a1: 0.5 };
1861 assert!(il.eval(200.0) > il.eval(5.0));
1862 let rr = EnergyLaw::ExpMilliEv { kappa: 25.0 };
1864 assert!(rr.eval(10.0) < 1e-6); assert!(rr.eval(0.001) > 0.9); }
1867
1868 #[test]
1874 fn synthesized_table_agrees_with_the_model_it_came_from() {
1875 let model = IkedaCarpenter::new(
1876 IkedaCarpenterParams {
1877 alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
1878 beta: EnergyLaw::Const(0.25),
1879 r: EnergyLaw::Const(0.15),
1880 burst_sigma_us: None,
1881 channel_fwhm_us: None,
1882 detector_tail: None,
1883 },
1884 25.0,
1885 &SynthesisGrid {
1886 e_min_ev: 1.0,
1887 e_max_ev: 100.0,
1888 n_energies: 16,
1889 n_tau: 512,
1890 },
1891 )
1892 .expect("valid IC");
1893 let table = model.tabulated();
1894
1895 for energy in [3.7_f64, 17.3, 55.0] {
1898 let tof = TOF_FACTOR * 25.0 / energy.sqrt();
1899 let edges: Vec<f64> = (0..=3000).map(|i| tof - 5.0 + i as f64 * 0.01).collect();
1900 let mean = |p: &[f64]| -> f64 {
1901 let mass: f64 = p.iter().sum();
1902 p.iter()
1903 .enumerate()
1904 .map(|(i, w)| w * 0.5 * (edges[i] + edges[i + 1]))
1905 .sum::<f64>()
1906 / mass
1907 };
1908 let from_model = model
1909 .detector_bin_probabilities(energy, &edges, 0.0)
1910 .expect("model evaluates");
1911 let from_table = table
1912 .detector_bin_probabilities(energy, &edges, 0.0)
1913 .expect("table evaluates");
1914 let peak = from_model.iter().copied().fold(0.0_f64, f64::max);
1915 let shape = from_model
1916 .iter()
1917 .zip(&from_table)
1918 .map(|(a, b)| (a - b).abs())
1919 .fold(0.0_f64, f64::max);
1920 assert!(
1921 shape < 0.01 * peak,
1922 "E={energy}: table differs from the analytic pulse by {:.2}% of peak",
1923 100.0 * shape / peak
1924 );
1925 let gap = mean(&from_model) - mean(&from_table);
1926 assert!(
1927 gap.abs() < 0.01,
1928 "E={energy}: model puts the pulse at {:.4} µs, its own table at \
1929 {:.4} µs ({gap:+.4} µs apart)",
1930 mean(&from_model),
1931 mean(&from_table)
1932 );
1933 }
1934 }
1935
1936 #[test]
1937 fn kernel_tail_points_to_positive_offset() {
1938 let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.2);
1940 let (offsets, weights) = synth_kernel(&p, 600, 10.0).unwrap();
1941 let max_pos = offsets.iter().cloned().fold(f64::MIN, f64::max);
1942 let min_neg = offsets.iter().cloned().fold(f64::MAX, f64::min);
1943 assert!(
1944 max_pos > min_neg.abs(),
1945 "expected longer +offset tail: +{max_pos} vs −{}",
1946 min_neg.abs()
1947 );
1948 let pos_w: f64 = offsets
1949 .iter()
1950 .zip(&weights)
1951 .filter(|(o, _)| **o > 0.0)
1952 .map(|(_, w)| *w)
1953 .sum();
1954 let neg_w: f64 = offsets
1955 .iter()
1956 .zip(&weights)
1957 .filter(|(o, _)| **o < 0.0)
1958 .map(|(_, w)| *w)
1959 .sum();
1960 assert!(pos_w > neg_w, "expected more weight at +offset");
1961 }
1962
1963 #[test]
1964 fn higher_energy_gives_narrower_kernel() {
1965 let p = IkedaCarpenterParams {
1967 alpha: EnergyLaw::SqrtE { a0: 0.3, a1: 0.0 },
1968 beta: EnergyLaw::Const(0.1),
1969 r: EnergyLaw::Const(0.0),
1970 burst_sigma_us: None,
1971 channel_fwhm_us: None,
1972 detector_tail: None,
1973 };
1974 let support = |e: f64| {
1975 let (o, _) = synth_kernel(&p, 600, e).unwrap();
1976 o.iter().cloned().fold(f64::MIN, f64::max) - o.iter().cloned().fold(f64::MAX, f64::min)
1977 };
1978 assert!(support(100.0) < support(5.0));
1979 }
1980
1981 #[test]
1982 fn synthesize_builds_valid_ascending_table() {
1983 let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.2);
1984 let grid = SynthesisGrid {
1985 e_min_ev: 0.5e-3,
1986 e_max_ev: 1000.0,
1987 n_energies: 32,
1988 n_tau: 400,
1989 };
1990 let ic = IkedaCarpenter::new(p, 25.0, &grid).expect("synthesis");
1991 assert_eq!(ic.ref_energies().len(), 32);
1992 assert_eq!(ic.tabulated().ref_energies().len(), 32);
1993 for w in ic.ref_energies().windows(2) {
1994 assert!(w[1] > w[0]);
1995 }
1996 }
1997
1998 #[test]
1999 fn rejects_bad_config() {
2000 let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.2);
2001 let bad = SynthesisGrid {
2002 e_min_ev: 1.0,
2003 e_max_ev: 0.5,
2004 n_energies: 16,
2005 n_tau: 100,
2006 };
2007 assert!(IkedaCarpenter::new(p.clone(), 25.0, &bad).is_err());
2008 assert!(IkedaCarpenter::new(p, -1.0, &SynthesisGrid::new(1.0, 10.0)).is_err());
2009 let neg_alpha = IkedaCarpenterParams {
2011 alpha: EnergyLaw::Const(-1.0),
2012 ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
2013 };
2014 assert!(IkedaCarpenter::new(neg_alpha, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err());
2015 for bad_width in [-1.0, f64::NAN, f64::INFINITY] {
2018 let neg_burst = IkedaCarpenterParams {
2019 burst_sigma_us: Some(bad_width),
2020 ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
2021 };
2022 assert!(
2023 IkedaCarpenter::new(neg_burst, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err(),
2024 "burst_sigma_us={bad_width} should be rejected"
2025 );
2026 let neg_chan = IkedaCarpenterParams {
2027 channel_fwhm_us: Some(bad_width),
2028 ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
2029 };
2030 assert!(
2031 IkedaCarpenter::new(neg_chan, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err(),
2032 "channel_fwhm_us={bad_width} should be rejected"
2033 );
2034 }
2035 }
2036
2037 #[test]
2038 fn broadens_constant_to_constant() {
2039 let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.3);
2041 let ic = IkedaCarpenter::new(p, 25.0, &SynthesisGrid::new(1.0, 200.0)).unwrap();
2042 let energies: Vec<f64> = (0..400).map(|i| 1.0 + i as f64 * 0.5).collect();
2043 let spectrum = vec![0.7f64; energies.len()];
2044 let res = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
2045 let out = apply_resolution(&energies, &spectrum, &res).unwrap();
2046 for v in &out[40..energies.len() - 40] {
2048 assert!((v - 0.7).abs() < 1e-3, "flat broadening drifted: {v}");
2049 }
2050 }
2051
2052 #[test]
2053 fn ic_centering_shifts_broadened_symmetric_dip_with_alpha() {
2054 const E0: f64 = 20.0;
2061 fn dip_centroid_energy(alpha: f64) -> f64 {
2064 let p = IkedaCarpenterParams::constant(alpha, 0.1, 0.0);
2066 let ic = IkedaCarpenter::new(p, 25.0, &SynthesisGrid::new(1.0, 200.0)).unwrap();
2067 let res = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
2068 let energies: Vec<f64> = (0..4000).map(|i| 10.0 + i as f64 * 0.005).collect();
2070 let spectrum: Vec<f64> = energies
2071 .iter()
2072 .map(|&e| 1.0 - 0.8 * (-((e - E0) / 0.1).powi(2)).exp())
2073 .collect();
2074 let out = apply_resolution(&energies, &spectrum, &res).unwrap();
2075 let (mut num, mut den) = (0.0, 0.0);
2076 for (&e, &t) in energies.iter().zip(&out) {
2077 if (e - E0).abs() <= 2.0 {
2078 let a = (1.0 - t).max(0.0); num += e * a;
2080 den += a;
2081 }
2082 }
2083 num / den
2084 }
2085 let signed_small_alpha = dip_centroid_energy(0.8) - E0;
2086 let signed_large_alpha = dip_centroid_energy(2.0) - E0;
2087 assert!(
2092 signed_small_alpha < 0.0 && signed_large_alpha < 0.0,
2093 "centering shift must be toward lower energy: \
2094 α=0.8 {signed_small_alpha:+e}, α=2.0 {signed_large_alpha:+e}"
2095 );
2096 let shift_small_alpha = signed_small_alpha.abs();
2097 let shift_large_alpha = signed_large_alpha.abs();
2098 assert!(
2100 shift_small_alpha > 1e-3,
2101 "centering shift vanished: {shift_small_alpha}"
2102 );
2103 assert!(
2105 shift_small_alpha > 1.3 * shift_large_alpha,
2106 "shift should scale ~1/α: α=0.8 {shift_small_alpha} vs α=2.0 {shift_large_alpha}"
2107 );
2108 }
2109
2110 #[test]
2111 fn plan_path_matches_direct_path() {
2112 let p = IkedaCarpenterParams::constant(1.2, 0.15, 0.25);
2113 let ic = IkedaCarpenter::new(p, 25.0, &SynthesisGrid::new(1.0, 200.0)).unwrap();
2114 let res = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
2115 let energies: Vec<f64> = (0..300).map(|i| 1.0 + i as f64 * 0.6).collect();
2116 let spectrum: Vec<f64> = energies
2118 .iter()
2119 .map(|&e| if (e - 90.0).abs() < 5.0 { 0.2 } else { 1.0 })
2120 .collect();
2121 let direct = apply_resolution(&energies, &spectrum, &res).unwrap();
2122 let plan = build_resolution_plan(&energies, &res).unwrap();
2123 let planned =
2124 apply_resolution_with_plan(plan.as_ref(), &energies, &spectrum, &res).unwrap();
2125 assert_eq!(direct.len(), planned.len());
2126 for (a, b) in direct.iter().zip(&planned) {
2127 assert!((a - b).abs() < 1e-12, "plan vs direct mismatch: {a} vs {b}");
2128 }
2129 }
2130
2131 #[test]
2132 fn tau_step_anchors_to_prompt_core_not_storage_tail() {
2133 let n_tau = 600;
2138 let alpha = 2.0; let prompt_step = (18.0 / alpha) / (n_tau as f64 - 1.0);
2140
2141 let p = IkedaCarpenterParams {
2142 channel_fwhm_us: Some(0.35),
2143 ..IkedaCarpenterParams::constant(alpha, 0.25, 0.5)
2144 };
2145 let (offs, wts) = synth_kernel(&p, n_tau, 10.0).unwrap();
2146 let dtau = offs[1] - offs[0];
2147 assert!(
2148 (dtau - prompt_step).abs() < 1e-12,
2149 "uncapped step {dtau} != prompt-core step {prompt_step}"
2150 );
2151 assert!(
2152 dtau <= prompt_step + 1e-12,
2153 "prompt-core spacing {dtau} > fast_reach/(n_tau−1) = {prompt_step}"
2154 );
2155 let tri = triangle_kernel(dtau, 0.35);
2157 let nonzero_per_side = tri.iter().take(tri.len() / 2).filter(|&&v| v > 0.0).count();
2158 assert!(
2159 nonzero_per_side >= 3,
2160 "triangle degenerated: {nonzero_per_side} nonzero samples per side"
2161 );
2162 let span = offs.last().unwrap() - offs.first().unwrap();
2169 let peak = wts.iter().cloned().fold(f64::MIN, f64::max);
2170 assert!((peak - 1.0).abs() < 1e-12, "weights not peak-normalized");
2171 assert!(span > 3.0 / alpha, "kernel span {span} lost the pulse body");
2172 assert!(
2173 span > 40.0,
2174 "kernel span {span} µs stops short of the β = 0.25 storage tail \
2175 (trim horizon ≈ 63 µs)"
2176 );
2177
2178 let p_ext = IkedaCarpenterParams {
2183 channel_fwhm_us: Some(0.35),
2184 ..IkedaCarpenterParams::constant(alpha, 0.02, 0.5)
2185 };
2186 let (offs_ext, _) = synth_kernel(&p_ext, n_tau, 10.0).unwrap();
2187 let dtau_ext = offs_ext[1] - offs_ext[0];
2188 let capped_step = 800.0 / (MAX_TAU_SAMPLES as f64 - 1.0);
2189 assert!(
2190 (dtau_ext - capped_step).abs() < 1e-9,
2191 "capped step {dtau_ext} != tau_max/(MAX_TAU_SAMPLES−1) = {capped_step}"
2192 );
2193 assert!(
2194 dtau_ext <= 0.35 / 3.0,
2195 "capped step {dtau_ext} cannot resolve the 0.35 µs triangle"
2196 );
2197 let tri_ext = triangle_kernel(dtau_ext, 0.35);
2198 let nonzero_ext = tri_ext
2199 .iter()
2200 .take(tri_ext.len() / 2)
2201 .filter(|&&v| v > 0.0)
2202 .count();
2203 assert!(
2204 nonzero_ext >= 3,
2205 "triangle degenerated under cap: {nonzero_ext} nonzero samples per side"
2206 );
2207 }
2208
2209 #[test]
2210 fn burst_and_channel_broaden_further() {
2211 let base = IkedaCarpenterParams::constant(1.0, 0.1, 0.0);
2213 let (o0, _) = synth_kernel(&base, 600, 10.0).unwrap();
2214 let support0 = o0.iter().cloned().fold(f64::MIN, f64::max)
2215 - o0.iter().cloned().fold(f64::MAX, f64::min);
2216 let folded = IkedaCarpenterParams {
2217 burst_sigma_us: Some(0.3),
2218 channel_fwhm_us: Some(0.35),
2219 ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
2220 };
2221 let (o1, _) = synth_kernel(&folded, 600, 10.0).unwrap();
2222 let support1 = o1.iter().cloned().fold(f64::MIN, f64::max)
2223 - o1.iter().cloned().fold(f64::MAX, f64::min);
2224 assert!(support1 > support0, "folded {support1} !> bare {support0}");
2225 }
2226
2227 fn kernel_variance(offsets: &[f64], weights: &[f64]) -> f64 {
2230 let w_sum: f64 = weights.iter().sum();
2231 let mean: f64 = offsets.iter().zip(weights).map(|(o, w)| o * w).sum::<f64>() / w_sum;
2232 offsets
2233 .iter()
2234 .zip(weights)
2235 .map(|(o, w)| (o - mean).powi(2) * w)
2236 .sum::<f64>()
2237 / w_sum
2238 }
2239
2240 #[test]
2241 fn unresolvable_tau_grid_is_rejected_loudly() {
2242 let p1 = IkedaCarpenterParams {
2248 channel_fwhm_us: Some(0.35),
2249 ..IkedaCarpenterParams::constant(2.0, 0.005, 0.5)
2250 };
2251 let err = IkedaCarpenter::new(p1.clone(), 25.0, &SynthesisGrid::new(1.0, 100.0))
2252 .expect_err("a delta-degenerate fold must be rejected");
2253 let msg = format!("{err:?}");
2254 assert!(
2255 msg.contains("cannot resolve") && msg.contains("Increase"),
2256 "error must diagnose the cap and name remedies: {msg}"
2257 );
2258 assert!(synth_kernel(&p1, 600, 10.0).is_err());
2260
2261 let p2 = IkedaCarpenterParams::constant(250.0, 0.02, 0.1);
2266 assert!(
2267 IkedaCarpenter::new(p2, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err(),
2268 "a capped step wider than the prompt core must be rejected"
2269 );
2270 }
2271
2272 #[test]
2273 fn requested_fold_is_never_a_silent_no_op() {
2274 let base = IkedaCarpenterParams::constant(2.0, 0.05, 0.5);
2280 let folded = IkedaCarpenterParams {
2281 channel_fwhm_us: Some(0.35),
2282 ..base.clone()
2283 };
2284 let (o0, w0) = synth_kernel(&base, 600, 10.0).unwrap();
2285 let (o1, w1) = synth_kernel(&folded, 600, 10.0).unwrap();
2286 let dv = kernel_variance(&o1, &w1) - kernel_variance(&o0, &w0);
2287 let expected = 0.35f64.powi(2) / 6.0;
2288 assert!(
2289 dv > 0.5 * expected,
2290 "fold variance increment {dv} µs² vs analytic {expected} µs²: \
2291 the requested fold (nearly) vanished"
2292 );
2293 }
2294
2295 #[test]
2296 fn boundary_step_fwhm_over_3_is_admitted_and_fold_survives() {
2297 let fwhm = 1.0;
2310 let p = IkedaCarpenterParams {
2311 channel_fwhm_us: Some(fwhm),
2312 ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
2313 };
2314 let (offs, _) = synth_kernel(&p, MIN_N_TAU, 10.0)
2315 .expect("the dtau = FWHM/3 boundary must be admitted, not rejected");
2316 let dtau = offs[1] - offs[0];
2317 let boundary = fwhm / TRI_MIN_SAMPLES_PER_SIDE;
2318 assert!(
2319 (dtau - boundary).abs() < 1e-12,
2320 "step {dtau} µs is not the FWHM/3 boundary {boundary} µs"
2321 );
2322
2323 let tri = triangle_kernel(dtau, fwhm);
2326 assert!(tri.len() >= 7, "boundary triangle too short: {}", tri.len());
2327 let per_side = tri
2328 .iter()
2329 .take(tri.len() / 2)
2330 .filter(|&&v| v > 1e-9)
2331 .count();
2332 assert_eq!(
2333 per_side, 2,
2334 "boundary triangle must keep 2 strictly interior nonzero samples \
2335 per side: {tri:?}"
2336 );
2337 let center = tri[tri.len() / 2];
2338 assert!(
2339 center < 0.5,
2340 "center weight {center} — boundary triangle degenerated toward a delta"
2341 );
2342
2343 let tri_offs: Vec<f64> = (0..tri.len())
2348 .map(|i| (i as f64 - (tri.len() / 2) as f64) * dtau)
2349 .collect();
2350 let v = kernel_variance(&tri_offs, &tri);
2351 let want = 4.0 * fwhm * fwhm / 27.0;
2352 assert!(
2353 ((v - want) / want).abs() < 0.02,
2354 "boundary triangle variance {v} µs² vs discrete analytic {want} µs²"
2355 );
2356 assert!(
2357 v > 0.5 * (fwhm * fwhm / 6.0),
2358 "boundary fold variance {v} µs² collapsed below half the \
2359 continuous FWHM²/6 — fold (nearly) vanished"
2360 );
2361 }
2362
2363 #[test]
2364 fn fold_variance_matches_analytic_oracle() {
2365 let base = IkedaCarpenterParams::constant(2.0, 0.1, 0.0);
2374 let (o0, w0) = synth_kernel(&base, 600, 10.0).unwrap();
2375 let v0 = kernel_variance(&o0, &w0);
2376 let v_gamma = 3.0 / (2.0f64 * 2.0);
2378 assert!(
2379 (v0 - v_gamma).abs() < 0.01 * v_gamma,
2380 "bare pulse variance {v0}, Gamma(3) analytic {v_gamma}"
2381 );
2382
2383 let fwhm = 0.35;
2384 let tri = IkedaCarpenterParams {
2385 channel_fwhm_us: Some(fwhm),
2386 ..base.clone()
2387 };
2388 let (o1, w1) = synth_kernel(&tri, 600, 10.0).unwrap();
2389 let dv_tri = kernel_variance(&o1, &w1) - v0;
2390 let want_tri = fwhm * fwhm / 6.0;
2391 assert!(
2392 ((dv_tri - want_tri) / want_tri).abs() < 0.02,
2393 "triangle fold added {dv_tri} µs², analytic FWHM²/6 = {want_tri} µs²"
2394 );
2395
2396 let sigma = 0.3;
2397 let gau = IkedaCarpenterParams {
2398 burst_sigma_us: Some(sigma),
2399 ..base.clone()
2400 };
2401 let (o2, w2) = synth_kernel(&gau, 600, 10.0).unwrap();
2402 let dv_gau = kernel_variance(&o2, &w2) - v0;
2403 let want_gau = sigma * sigma;
2404 assert!(
2405 ((dv_gau - want_gau) / want_gau).abs() < 0.02,
2406 "Gaussian burst added {dv_gau} µs², analytic σ² = {want_gau} µs²"
2407 );
2408 }
2409}